diff --git a/.gitignore b/.gitignore index 900f432a..f019283b 100644 --- a/.gitignore +++ b/.gitignore @@ -127,4 +127,7 @@ cython_debug/ .vscode/ # Binary files -bin/ \ No newline at end of file +bin/ + +# Claude +.claude/ \ No newline at end of file diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py index 2751bf41..a143c6a3 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/__init__.py @@ -21,6 +21,16 @@ AgenticUserAuthorization, ) +# Proactive +from .proactive import ( + Conversation, + ConversationBuilder, + ConversationReferenceBuilder, + CreateConversationOptions, + Proactive, + ProactiveOptions, +) + # App State from .state.conversation_state import ConversationState from .state.state import State, StatePropertyAccessor, state @@ -47,4 +57,11 @@ "Authorization", "AuthHandler", "AgenticUserAuthorization", + # Proactive + "Conversation", + "ConversationBuilder", + "ConversationReferenceBuilder", + "CreateConversationOptions", + "Proactive", + "ProactiveOptions", ] 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 c8633ecf..fbfdf8a2 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 @@ -45,6 +45,7 @@ from ._type_defs import RouteHandler, RouteSelector from ._routes import _RouteList, _Route, RouteRank, _agentic_selector +from .proactive import Proactive, ProactiveOptions logger = logging.getLogger(__name__) @@ -69,6 +70,7 @@ class AgentApplication(Agent, Generic[StateT]): _options: ApplicationOptions _adapter: Optional[ChannelServiceAdapter] = None _auth: Optional[Authorization] = None + _proactive: Optional[Proactive] = None _internal_before_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _internal_after_turn: list[Callable[[TurnContext, StateT], Awaitable[bool]]] = [] _route_list: _RouteList[StateT] = _RouteList[StateT]() @@ -147,6 +149,12 @@ def __init__( or partial(TurnState.with_storage, self._options.storage) ) + if options.proactive: + proactive_opts = copy(options.proactive) + if not proactive_opts.storage: + proactive_opts.storage = self._options.storage + self._proactive = Proactive(self, proactive_opts) + # TODO: decide how to initialize the Authorization (params vs options vs kwargs) if authorization: self._auth = authorization @@ -216,6 +224,26 @@ def options(self) -> ApplicationOptions: """ return self._options + @property + def proactive(self) -> Proactive: + """ + The application's proactive messaging manager. + + :return: The proactive messaging manager. + :rtype: :class:`microsoft_agents.hosting.core.app.proactive.proactive.Proactive` + :raises ApplicationError: If proactive options were not configured. + """ + if not self._proactive: + logger.error( + "AgentApplication.proactive(): proactive options are not configured.", + stack_info=True, + ) + raise ApplicationError(""" + The `AgentApplication.proactive` property is unavailable because + no ProactiveOptions were configured in ApplicationOptions. + """) + return self._proactive + def add_route( self, selector: RouteSelector, diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py index 21312c76..a66ca494 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/app_options.py @@ -17,6 +17,7 @@ from ..channel_service_adapter import ChannelServiceAdapter from .state.turn_state import TurnState +from .proactive.proactive_options import ProactiveOptions # from .teams_adapter import TeamsAdapter @@ -89,3 +90,10 @@ class ApplicationOptions: Optional. Authorization handler for OAuth flows. If not provided, no OAuth flows will be supported. """ + + proactive: Optional[ProactiveOptions] = None + """ + Optional. Options for the proactive messaging subsystem. + When set, :attr:`AgentApplication.proactive` is available for storing + conversations and initiating proactive turns. + """ diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/__init__.py new file mode 100644 index 00000000..f0d7eaa9 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/__init__.py @@ -0,0 +1,20 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from .conversation import Conversation +from .conversation_builder import ConversationBuilder +from .conversation_reference_builder import ConversationReferenceBuilder +from .create_conversation_options import CreateConversationOptions +from .proactive import Proactive +from .proactive_options import ProactiveOptions + +__all__ = [ + "Conversation", + "ConversationBuilder", + "ConversationReferenceBuilder", + "CreateConversationOptions", + "Proactive", + "ProactiveOptions", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py new file mode 100644 index 00000000..d75b3be1 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation.py @@ -0,0 +1,152 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional, TYPE_CHECKING + +from microsoft_agents.activity import ConversationReference +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.storage.store_item import StoreItem + +if TYPE_CHECKING: + from microsoft_agents.hosting.core.turn_context import TurnContext + from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + +# JWT claim keys that are persisted alongside a ConversationReference. +_PERSISTED_CLAIM_KEYS = frozenset({"aud", "azp", "appid", "idtyp", "ver", "iss", "tid"}) + + +class Conversation(StoreItem): + """ + Bundles a :class:`~microsoft_agents.activity.ConversationReference` together + with a filtered set of JWT claims so that a proactive continuation can be + performed without holding onto the full :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. + + Instances are typically created via + :meth:`~microsoft_agents.hosting.core.app.proactive.conversation_builder.ConversationBuilder` + or via :meth:`from_turn_context`. + + :param claims: Filtered JWT claims (``aud``, ``azp``, ``appid``, ``idtyp``, + ``ver``, ``iss``, ``tid``). May be a raw ``dict`` or a + :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. + :type claims: dict[str, str] or ClaimsIdentity + :param conversation_reference: The conversation reference. + :type conversation_reference: :class:`~microsoft_agents.activity.ConversationReference` + """ + + def __init__( + self, + claims: "dict[str, str] | ClaimsIdentity", + conversation_reference: ConversationReference, + ) -> None: + if isinstance(claims, ClaimsIdentity): + self.claims: dict[str, str] = Conversation.claims_from_identity(claims) + else: + self.claims = { + k: v for k, v in claims.items() if k in _PERSISTED_CLAIM_KEYS + } + self.conversation_reference: ConversationReference = conversation_reference + + # ------------------------------------------------------------------ + # Factory helpers + # ------------------------------------------------------------------ + + @classmethod + def from_turn_context(cls, context: "TurnContext") -> "Conversation": + """ + Create a :class:`Conversation` from the current turn context. + + :param context: The active turn context. + :type context: :class:`~microsoft_agents.hosting.core.turn_context.TurnContext` + :return: A new :class:`Conversation` capturing the current turn's identity + and conversation reference. + :rtype: :class:`Conversation` + """ + from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + + identity: Optional[ClaimsIdentity] = context.turn_state.get( + ChannelAdapter.AGENT_IDENTITY_KEY + ) + reference = context.activity.get_conversation_reference() + return cls(identity or {}, reference) + + # ------------------------------------------------------------------ + # Claims helpers + # ------------------------------------------------------------------ + + @staticmethod + def claims_from_identity(identity: ClaimsIdentity) -> "dict[str, str]": + """ + Return the subset of claims from *identity* that are relevant for proactive + messaging (``aud``, ``azp``, ``appid``, ``idtyp``, ``ver``, ``iss``, ``tid``). + + :param identity: The full claims identity. + :type identity: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + :return: Filtered claims dictionary. + :rtype: dict[str, str] + """ + return {k: v for k, v in identity.claims.items() if k in _PERSISTED_CLAIM_KEYS} + + @staticmethod + def identity_from_claims(claims: "dict[str, str]") -> ClaimsIdentity: + """ + Reconstruct a :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + from a previously persisted claims dict. + + :param claims: Filtered claims dictionary (as produced by :meth:`claims_from_identity`). + :type claims: dict[str, str] + :return: Reconstituted claims identity. + :rtype: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + """ + return ClaimsIdentity(claims=dict(claims), is_authenticated=True) + + # ------------------------------------------------------------------ + # Validation + # ------------------------------------------------------------------ + + def validate(self) -> None: + """ + Raise :exc:`ValueError` if required fields are missing. + + :raises ValueError: If ``conversation_reference``, its nested + ``conversation``, or ``service_url`` are absent. + """ + if not self.conversation_reference: + raise ValueError("Conversation.conversation_reference is required.") + if not self.conversation_reference.conversation: + raise ValueError( + "Conversation.conversation_reference.conversation is required." + ) + if not self.conversation_reference.conversation.id: + raise ValueError( + "Conversation.conversation_reference.conversation.id is required." + ) + if not self.conversation_reference.service_url: + raise ValueError( + "Conversation.conversation_reference.service_url is required." + ) + + # ------------------------------------------------------------------ + # StoreItem serialization + # ------------------------------------------------------------------ + + def store_item_to_json(self) -> dict: + return { + "claims": self.claims, + "conversation_reference": self.conversation_reference.model_dump( + mode="json", by_alias=True, exclude_unset=True + ), + } + + @staticmethod + def from_json_to_store_item(json_data: dict) -> "Conversation": + reference = ConversationReference.model_validate( + json_data.get("conversation_reference", {}) + ) + return Conversation( + claims=json_data.get("claims", {}), + conversation_reference=reference, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py new file mode 100644 index 00000000..6c800bed --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_builder.py @@ -0,0 +1,237 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional + +from microsoft_agents.activity import ( + ChannelAccount, + Channels, + ConversationAccount, + ConversationReference, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + +from .conversation import Conversation +from .conversation_reference_builder import _service_url_for_channel + + +class ConversationBuilder: + """ + Fluent builder for :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + + Typical usage — building from a minimal set of claims:: + + conversation = ( + ConversationBuilder + .create("agent-app-id", "msteams", service_url="https://smba.trafficmanager.net/teams/") + .with_user("user-aad-oid", "User Display Name") + .with_conversation("19:thread-id@thread.v2") + .build() + ) + + Or from an existing :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`:: + + conversation = ( + ConversationBuilder + .create_from_identity(claims_identity, "msteams") + .with_conversation("19:thread-id@thread.v2") + .build() + ) + """ + + def __init__(self) -> None: + self._claims: dict[str, str] = {} + self._channel_id: Optional[str] = None + self._service_url: Optional[str] = None + self._agent_id: Optional[str] = None + self._agent_name: Optional[str] = None + self._user_id: Optional[str] = None + self._user_name: Optional[str] = None + self._conversation_id: Optional[str] = None + self._conversation_name: Optional[str] = None + self._tenant_id: Optional[str] = None + self._activity_id: Optional[str] = None + + # ------------------------------------------------------------------ + # Entry-point factories + # ------------------------------------------------------------------ + + @classmethod + def create( + cls, + agent_client_id: str, + channel_id: str, + service_url: Optional[str] = None, + requestor_id: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Start building a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + from a minimal set of claims. + + :param agent_client_id: The agent's AAD application ID (becomes the ``aud`` + claim and optionally the ``appid`` claim). + :type agent_client_id: str + :param channel_id: The channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :param service_url: Override the service URL. Defaults to the canonical + URL for *channel_id*. + :type service_url: Optional[str] + :param requestor_id: If provided, stored as the ``appid`` claim (useful + when the requestor differs from the audience). + :type requestor_id: Optional[str] + :return: A builder pre-populated with the supplied claims. + :rtype: :class:`ConversationBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._service_url = service_url or _service_url_for_channel(channel_id) + builder._claims["aud"] = agent_client_id + if requestor_id: + builder._claims["appid"] = requestor_id + + # Set agent ID with Teams prefix if needed. + if channel_id == Channels.ms_teams or channel_id == "msteams": + builder._agent_id = f"28:{agent_client_id}" + else: + builder._agent_id = agent_client_id + + return builder + + @classmethod + def create_from_identity( + cls, + identity: ClaimsIdentity, + channel_id: str, + service_url: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Start building a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + from a full :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity`. + + :param identity: The claims identity to extract claims from. + :type identity: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + :param channel_id: The channel identifier. + :type channel_id: str + :param service_url: Override the service URL. + :type service_url: Optional[str] + :return: A builder pre-populated with the identity's claims. + :rtype: :class:`ConversationBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._service_url = service_url or _service_url_for_channel(channel_id) + builder._claims = Conversation.claims_from_identity(identity) + + app_id = identity.get_app_id() + if app_id: + if channel_id == Channels.ms_teams or channel_id == "msteams": + builder._agent_id = f"28:{app_id}" + else: + builder._agent_id = app_id + + return builder + + # ------------------------------------------------------------------ + # Fluent setters + # ------------------------------------------------------------------ + + def with_user( + self, + user_id: str, + user_name: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Set the user account. + + :param user_id: The user's channel account ID. + :type user_id: str + :param user_name: Optional display name. + :type user_name: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationBuilder` + """ + self._user_id = user_id + self._user_name = user_name + return self + + def with_conversation( + self, + conversation_id: str, + conversation_name: Optional[str] = None, + tenant_id: Optional[str] = None, + ) -> "ConversationBuilder": + """ + Set the conversation account details. + + :param conversation_id: The conversation ID. + :type conversation_id: str + :param conversation_name: Optional conversation name. + :type conversation_name: Optional[str] + :param tenant_id: Optional tenant ID. + :type tenant_id: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationBuilder` + """ + self._conversation_id = conversation_id + self._conversation_name = conversation_name + self._tenant_id = tenant_id + return self + + def with_activity_id(self, activity_id: str) -> "ConversationBuilder": + """ + Set the activity ID on the underlying conversation reference. + + :param activity_id: The activity ID. + :type activity_id: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationBuilder` + """ + self._activity_id = activity_id + return self + + # ------------------------------------------------------------------ + # Build + # ------------------------------------------------------------------ + + def build(self) -> Conversation: + """ + Construct the :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + + :raises ValueError: If required fields (``channel_id``, ``conversation_id``) are missing. + :return: The built :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + :rtype: :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + """ + if not self._channel_id: + raise ValueError("ConversationBuilder: channel_id is required.") + if not self._conversation_id: + raise ValueError("ConversationBuilder: conversation_id is required.") + + agent = ( + ChannelAccount(id=self._agent_id, name=self._agent_name) + if self._agent_id + else None + ) + user = ( + ChannelAccount(id=self._user_id, name=self._user_name) + if self._user_id + else None + ) + + reference = ConversationReference( + channel_id=self._channel_id, + service_url=self._service_url or _service_url_for_channel(self._channel_id), + conversation=ConversationAccount( + id=self._conversation_id, + name=self._conversation_name, + tenant_id=self._tenant_id, + ), + bot=agent, + user=user, + activity_id=self._activity_id, + ) + + return Conversation(claims=self._claims, conversation_reference=reference) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py new file mode 100644 index 00000000..05189da3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/conversation_reference_builder.py @@ -0,0 +1,238 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Optional + +from microsoft_agents.activity import ( + ChannelAccount, + Channels, + ConversationAccount, + ConversationReference, +) + + +def _service_url_for_channel(channel_id: str) -> str: + """Return the default service URL for a given channel. + + :param channel_id: The channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :return: The service URL for that channel. + :rtype: str + """ + if channel_id == Channels.ms_teams or channel_id == "msteams": + return "https://smba.trafficmanager.net/teams/" + return f"https://{channel_id}.botframework.com/" + + +class ConversationReferenceBuilder: + """ + Fluent builder for :class:`~microsoft_agents.activity.ConversationReference`. + + Typical usage:: + + reference = ( + ConversationReferenceBuilder + .create("msteams", "19:conversation-id@thread.v2") + .with_agent("28:agent-app-id", "My Agent") + .with_user("user-aad-oid", "User Display Name") + .with_locale("en-US") + .build() + ) + """ + + def __init__(self) -> None: + self._channel_id: Optional[str] = None + self._conversation_id: Optional[str] = None + self._service_url: Optional[str] = None + self._agent_id: Optional[str] = None + self._agent_name: Optional[str] = None + self._user_id: Optional[str] = None + self._user_name: Optional[str] = None + self._activity_id: Optional[str] = None + self._locale: Optional[str] = None + + # ------------------------------------------------------------------ + # Entry-point factories + # ------------------------------------------------------------------ + + @classmethod + def create( + cls, + channel_id: str, + conversation_id: str, + ) -> "ConversationReferenceBuilder": + """ + Start building a :class:`~microsoft_agents.activity.ConversationReference` + from a channel ID and an existing conversation ID. + + :param channel_id: The channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :param conversation_id: The existing conversation ID. + :type conversation_id: str + :return: A builder pre-populated with the channel and conversation. + :rtype: :class:`ConversationReferenceBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._conversation_id = conversation_id + return builder + + @classmethod + def create_for_agent( + cls, + agent_client_id: str, + channel_id: str, + service_url: Optional[str] = None, + ) -> "ConversationReferenceBuilder": + """ + Start building a :class:`~microsoft_agents.activity.ConversationReference` + from an agent application ID and channel. + + For the ``msteams`` channel the agent ID is automatically prefixed with + ``28:`` as required by Teams. + + :param agent_client_id: The agent's AAD application ID. + :type agent_client_id: str + :param channel_id: The channel identifier. + :type channel_id: str + :param service_url: Override the service URL. When ``None`` the default + URL for the channel is used. + :type service_url: Optional[str] + :return: A builder pre-populated for the agent. + :rtype: :class:`ConversationReferenceBuilder` + """ + builder = cls() + builder._channel_id = channel_id + builder._service_url = service_url or _service_url_for_channel(channel_id) + + # Teams requires the "28:" prefix on agent IDs. + if channel_id == Channels.ms_teams or channel_id == "msteams": + builder._agent_id = f"28:{agent_client_id}" + else: + builder._agent_id = agent_client_id + + return builder + + # ------------------------------------------------------------------ + # Fluent setters + # ------------------------------------------------------------------ + + def with_agent( + self, + agent_id: str, + agent_name: Optional[str] = None, + ) -> "ConversationReferenceBuilder": + """ + Set the agent (bot) account on the reference. + + :param agent_id: The agent's channel account ID. + :type agent_id: str + :param agent_name: Optional display name. + :type agent_name: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._agent_id = agent_id + self._agent_name = agent_name + return self + + def with_user( + self, + user_id: str, + user_name: Optional[str] = None, + ) -> "ConversationReferenceBuilder": + """ + Set the user account on the reference. + + :param user_id: The user's channel account ID. + :type user_id: str + :param user_name: Optional display name. + :type user_name: Optional[str] + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._user_id = user_id + self._user_name = user_name + return self + + def with_service_url(self, service_url: str) -> "ConversationReferenceBuilder": + """ + Override the service URL. + + :param service_url: The service URL to use. + :type service_url: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._service_url = service_url + return self + + def with_activity_id(self, activity_id: str) -> "ConversationReferenceBuilder": + """ + Set the activity ID on the reference. + + :param activity_id: The activity ID. + :type activity_id: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._activity_id = activity_id + return self + + def with_locale(self, locale: str) -> "ConversationReferenceBuilder": + """ + Set the locale on the reference. + + :param locale: BCP-47 locale string (e.g. ``"en-US"``). + :type locale: str + :return: ``self`` for chaining. + :rtype: :class:`ConversationReferenceBuilder` + """ + self._locale = locale + return self + + # ------------------------------------------------------------------ + # Build + # ------------------------------------------------------------------ + + def build(self) -> ConversationReference: + """ + Construct the :class:`~microsoft_agents.activity.ConversationReference`. + + :raises ValueError: If ``channel_id`` or ``conversation_id`` have not been set. + :return: The built conversation reference. + :rtype: :class:`~microsoft_agents.activity.ConversationReference` + """ + if not self._channel_id: + raise ValueError("ConversationReferenceBuilder: channel_id is required.") + if not self._conversation_id: + raise ValueError( + "ConversationReferenceBuilder: conversation_id is required." + ) + + service_url = self._service_url or _service_url_for_channel(self._channel_id) + + agent = ( + ChannelAccount(id=self._agent_id, name=self._agent_name) + if self._agent_id + else None + ) + user = ( + ChannelAccount(id=self._user_id, name=self._user_name) + if self._user_id + else None + ) + + return ConversationReference( + channel_id=self._channel_id, + conversation=ConversationAccount(id=self._conversation_id), + service_url=service_url, + bot=agent, + user=user, + activity_id=self._activity_id, + locale=self._locale, + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py new file mode 100644 index 00000000..f144c9e5 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/create_conversation_options.py @@ -0,0 +1,61 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +from microsoft_agents.activity import ConversationParameters +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +@dataclass +class CreateConversationOptions: + """ + Options for :meth:`~microsoft_agents.hosting.core.app.proactive.proactive.Proactive.create_conversation`. + + :param identity: The :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + used to authenticate the outbound call. + :type identity: :class:`~microsoft_agents.hosting.core.authorization.ClaimsIdentity` + :param channel_id: The target channel identifier (e.g. ``"msteams"``). + :type channel_id: str + :param parameters: The :class:`~microsoft_agents.activity.ConversationParameters` + passed to the channel when creating the conversation. + :type parameters: :class:`~microsoft_agents.activity.ConversationParameters` + :param service_url: Optional override for the channel service URL. + :type service_url: Optional[str] + :param audience: Optional OAuth audience override. When ``None`` the + audience is derived from *identity*. + :type audience: Optional[str] + :param store_conversation: When ``True`` the newly created conversation is + automatically stored via + :meth:`~microsoft_agents.hosting.core.app.proactive.proactive.Proactive.store_conversation` + so it can be resumed later. Defaults to ``False``. + :type store_conversation: bool + """ + + identity: ClaimsIdentity = field(default=None) + channel_id: str = field(default="") + parameters: Optional[ConversationParameters] = field(default=None) + service_url: Optional[str] = field(default=None) + audience: Optional[str] = field(default=None) + store_conversation: bool = field(default=False) + + def validate(self) -> None: + """ + Raise :exc:`ValueError` if required fields are missing. + + :raises ValueError: If ``identity``, ``channel_id``, ``parameters``, + or ``service_url`` are absent. + """ + if not self.identity: + raise ValueError("CreateConversationOptions.identity is required.") + if not self.channel_id: + raise ValueError("CreateConversationOptions.channel_id is required.") + if not self.parameters: + raise ValueError("CreateConversationOptions.parameters is required.") + if not self.service_url: + raise ValueError("CreateConversationOptions.service_url is required.") diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py new file mode 100644 index 00000000..68776d1b --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive.py @@ -0,0 +1,394 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +import logging +from typing import Awaitable, Callable, Generic, Optional, TypeVar, TYPE_CHECKING + +from microsoft_agents.activity import Activity, ResourceResponse + +from microsoft_agents.hosting.core.app.state.turn_state import TurnState +from microsoft_agents.hosting.core.storage import Storage + +from .conversation import Conversation +from .create_conversation_options import CreateConversationOptions +from .proactive_options import ProactiveOptions + +if TYPE_CHECKING: + from microsoft_agents.hosting.core.turn_context import TurnContext + from microsoft_agents.hosting.core.channel_service_adapter import ( + ChannelServiceAdapter, + ) + from microsoft_agents.hosting.core.app.agent_application import AgentApplication + +logger = logging.getLogger(__name__) + +StateT = TypeVar("StateT", bound=TurnState) + +_STORAGE_KEY_PREFIX = "proactive/conversations/" + +RouteHandler = Callable[["TurnContext", StateT], Awaitable[None]] + + +class Proactive(Generic[StateT]): + """ + Proactive messaging support for :class:`~microsoft_agents.hosting.core.app.agent_application.AgentApplication`. + + This class is attached to :attr:`AgentApplication.proactive` automatically when + :attr:`~microsoft_agents.hosting.core.app.app_options.ApplicationOptions.proactive` options are + provided. It provides methods to: + + * **Persist** a conversation reference so it can be resumed later + (:meth:`store_conversation`, :meth:`get_conversation`, + :meth:`delete_conversation`). + * **Continue** an existing conversation proactively + (:meth:`continue_conversation`). + * **Send** a single activity into an existing conversation + (:meth:`send_activity`). + * **Create** a brand-new conversation with a user + (:meth:`create_conversation`). + + Example — store then resume:: + + # During a normal turn, save the conversation for later: + await app.proactive.store_conversation(context) + + # Later (e.g. from a webhook), resume it: + async def notify(context, state): + await context.send_activity("Here is your notification!") + + await app.proactive.continue_conversation(adapter, conversation_id, notify) + """ + + def __init__( + self, + app: "AgentApplication[StateT]", + options: ProactiveOptions, + ) -> None: + self._app = app + self._options = options + + # ------------------------------------------------------------------ + # Storage helpers + # ------------------------------------------------------------------ + + @property + def _storage(self) -> Storage: + storage = self._options.storage or self._app.options.storage + if not storage: + raise RuntimeError( + "Proactive messaging requires a Storage instance. " + "Configure ProactiveOptions.storage or ApplicationOptions.storage." + ) + return storage + + @staticmethod + def _storage_key(conversation_id: str) -> str: + return f"{_STORAGE_KEY_PREFIX}{conversation_id}" + + # ------------------------------------------------------------------ + # Conversation persistence + # ------------------------------------------------------------------ + + async def store_conversation( + self, + context_or_conversation: "TurnContext | Conversation", + ) -> None: + """ + Persist a :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + to storage so it can be resumed later. + + Accepts either: + + * A :class:`~microsoft_agents.hosting.core.turn_context.TurnContext` — the + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + is built automatically from the current turn. + * A :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + — stored directly. + + :param context_or_conversation: The turn context or an already-built + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + :raises ValueError: If required fields on the conversation are missing. + """ + from microsoft_agents.hosting.core.turn_context import TurnContext + + if isinstance(context_or_conversation, TurnContext): + conversation = Conversation.from_turn_context(context_or_conversation) + else: + conversation = context_or_conversation + + conversation.validate() + key = self._storage_key(conversation.conversation_reference.conversation.id) + logger.debug("Storing conversation with key: %s", key) + await self._storage.write({key: conversation}) + + async def get_conversation(self, conversation_id: str) -> Optional[Conversation]: + """ + Retrieve a previously stored + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + + :param conversation_id: The conversation ID used as the storage key. + :type conversation_id: str + :return: The stored :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`, + or ``None`` if not found. + :rtype: Optional[:class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`] + """ + key = self._storage_key(conversation_id) + results = await self._storage.read([key], target_cls=Conversation) + return results.get(key) + + async def delete_conversation(self, conversation_id: str) -> None: + """ + Delete a previously stored conversation. + + :param conversation_id: The conversation ID to delete. + :type conversation_id: str + """ + key = self._storage_key(conversation_id) + logger.debug("Deleting conversation with key: %s", key) + await self._storage.delete([key]) + + # ------------------------------------------------------------------ + # Send a single activity + # ------------------------------------------------------------------ + + async def send_activity( + self, + adapter: "ChannelServiceAdapter", + conversation_id_or_conversation: "str | Conversation", + activity: Activity, + ) -> Optional[ResourceResponse]: + """ + Send a single activity into an existing conversation. + + :param adapter: The channel service adapter. + :type adapter: :class:`~microsoft_agents.hosting.core.channel_service_adapter.ChannelServiceAdapter` + :param conversation_id_or_conversation: Either a conversation ID string + (the conversation is loaded from storage) or a + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + object. + :type conversation_id_or_conversation: str or + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + :param activity: The activity to send. + :type activity: :class:`~microsoft_agents.activity.Activity` + :return: The :class:`~microsoft_agents.activity.ResourceResponse` from the + channel, or ``None``. + :rtype: Optional[:class:`~microsoft_agents.activity.ResourceResponse`] + :raises KeyError: If *conversation_id_or_conversation* is a string and the + conversation is not found in storage. + """ + conversation = await self._resolve_conversation(conversation_id_or_conversation) + return await Proactive._send_activity_impl(adapter, conversation, activity) + + @staticmethod + async def _send_activity_impl( + adapter: "ChannelServiceAdapter", + conversation: Conversation, + activity: Activity, + ) -> Optional[ResourceResponse]: + result: Optional[ResourceResponse] = None + captured_exc: Optional[BaseException] = None + + claims = Conversation.identity_from_claims(conversation.claims) + continuation = conversation.conversation_reference.get_continuation_activity() + + async def _callback(context: "TurnContext") -> None: + nonlocal result, captured_exc + try: + result = await context.send_activity(activity) + except Exception as exc: # noqa: BLE001 + captured_exc = exc + + await adapter.continue_conversation_with_claims(claims, continuation, _callback) + + if captured_exc is not None: + raise captured_exc + return result + + # ------------------------------------------------------------------ + # Continue a conversation + # ------------------------------------------------------------------ + + async def continue_conversation( + self, + adapter: "ChannelServiceAdapter", + conversation_id_or_conversation: "str | Conversation", + handler: RouteHandler, + *, + continuation_activity: Optional[Activity] = None, + token_handlers: Optional[list[str]] = None, + ) -> None: + """ + Continue an existing conversation by invoking *handler* inside a full + turn with state loaded/saved. + + :param adapter: The channel service adapter. + :type adapter: :class:`~microsoft_agents.hosting.core.channel_service_adapter.ChannelServiceAdapter` + :param conversation_id_or_conversation: Conversation ID (loaded from + storage) or a direct + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation`. + :type conversation_id_or_conversation: str or + :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + :param handler: Async callable ``(context, state) -> None`` that performs + the proactive work. + :type handler: Callable + :param continuation_activity: Optional override for the continuation activity + sent to the channel. When ``None`` the default continuation event from + :meth:`~microsoft_agents.activity.ConversationReference.get_continuation_activity` + is used. Supply a custom activity to carry additional payload (e.g. the + original message) into the proactive turn via ``context.activity.value``. + :type continuation_activity: Optional[:class:`~microsoft_agents.activity.Activity`] + :param token_handlers: Optional list of OAuth connection names whose + tokens must be available before *handler* is invoked. When + :attr:`~ProactiveOptions.fail_on_unsigned_in_connections` is ``True`` + (the default) and a token is missing a :exc:`RuntimeError` is raised. + :type token_handlers: Optional[list[str]] + :raises KeyError: If *conversation_id_or_conversation* is a string and the + conversation is not found in storage. + :raises RuntimeError: If a required OAuth token is not available and + :attr:`~ProactiveOptions.fail_on_unsigned_in_connections` is ``True``. + """ + conversation = await self._resolve_conversation(conversation_id_or_conversation) + + captured_exc: Optional[BaseException] = None + claims = Conversation.identity_from_claims(conversation.claims) + continuation = ( + continuation_activity + or conversation.conversation_reference.get_continuation_activity() + ) + + async def _callback(context: "TurnContext") -> None: + nonlocal captured_exc + try: + await self._on_turn(context, handler, token_handlers) + except Exception as exc: # noqa: BLE001 + captured_exc = exc + + await adapter.continue_conversation_with_claims(claims, continuation, _callback) + + if captured_exc is not None: + raise captured_exc + + # ------------------------------------------------------------------ + # Create a new conversation + # ------------------------------------------------------------------ + + async def create_conversation( + self, + adapter: "ChannelServiceAdapter", + options: CreateConversationOptions, + handler: Optional[RouteHandler] = None, + ) -> Conversation: + """ + Create a brand-new conversation with a user and optionally run *handler*. + + :param adapter: The channel service adapter. + :type adapter: :class:`~microsoft_agents.hosting.core.channel_service_adapter.ChannelServiceAdapter` + :param options: Options specifying the identity, channel, conversation + parameters, etc. + :type options: :class:`~microsoft_agents.hosting.core.app.proactive.create_conversation_options.CreateConversationOptions` + :param handler: Optional async callable invoked inside the new + conversation's first turn. + :type handler: Optional[Callable] + :return: A :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + representing the newly created conversation. + :rtype: :class:`~microsoft_agents.hosting.core.app.proactive.conversation.Conversation` + :raises ValueError: If required fields in *options* are missing. + """ + options.validate() + + new_conversation: Optional[Conversation] = None + captured_exc: Optional[BaseException] = None + + audience = options.audience or options.identity.get_token_audience() + + async def _callback(context: "TurnContext") -> None: + nonlocal new_conversation, captured_exc + try: + reference = context.activity.get_conversation_reference() + new_conversation = Conversation( + claims=options.identity, + conversation_reference=reference, + ) + + if options.store_conversation: + await self.store_conversation(new_conversation) + + if handler is not None: + state = await self._load_state(context) + await handler(context, state) + await state.save(context) + except Exception as exc: # noqa: BLE001 + captured_exc = exc + + await adapter.create_conversation( + options.identity.get_app_id() or "", + options.channel_id, + options.service_url, + audience, + options.parameters, + _callback, + ) + + if captured_exc is not None: + raise captured_exc + + return new_conversation + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _on_turn( + self, + context: "TurnContext", + handler: RouteHandler, + token_handlers: Optional[list[str]] = None, + ) -> None: + """Run a proactive turn: load state → optional OAuth check → handler → save state.""" + state = await self._load_state(context) + + if token_handlers and self._app._auth: + for handler_id in token_handlers: + result = await self._app._auth._start_or_continue_sign_in( + context, state, handler_id + ) + if not result.sign_in_complete(): + if self._options.fail_on_unsigned_in_connections: + raise RuntimeError( + f"Proactive continuation aborted: user is not signed in " + f"for OAuth connection '{handler_id}'." + ) + logger.warning( + "Proactive continuation skipped: user not signed in for '%s'.", + handler_id, + ) + return + + await handler(context, state) + await state.save(context) + + async def _load_state(self, context: "TurnContext") -> StateT: + if self._app._turn_state_factory: + state = self._app._turn_state_factory() + else: + state = TurnState.with_storage(self._storage) + await state.load(context, self._storage) + return state + + async def _resolve_conversation( + self, + conversation_id_or_conversation: "str | Conversation", + ) -> Conversation: + if isinstance(conversation_id_or_conversation, str): + conversation = await self.get_conversation(conversation_id_or_conversation) + if conversation is None: + raise KeyError( + f"Proactive conversation not found in storage: " + f"'{conversation_id_or_conversation}'" + ) + return conversation + return conversation_id_or_conversation diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py new file mode 100644 index 00000000..2ff0c0b3 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/app/proactive/proactive_options.py @@ -0,0 +1,35 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +from microsoft_agents.hosting.core.storage import Storage + + +@dataclass +class ProactiveOptions: + """ + Options for the Proactive messaging subsystem. + + :param storage: The storage instance used to persist and retrieve conversations. + :type storage: Optional[:class:`microsoft_agents.hosting.core.storage.Storage`] + :param fail_on_unsigned_in_connections: If ``True`` (the default), a + :exc:`RuntimeError` is raised when a required OAuth token is not available + during a proactive continuation. Set to ``False`` to silently skip the + handler instead. + :type fail_on_unsigned_in_connections: bool + """ + + storage: Optional[Storage] = None + """Storage used to persist Conversation objects.""" + + fail_on_unsigned_in_connections: bool = True + """ + When ``True`` (default), raise an error if a required OAuth connection is + not signed-in at the time a proactive continuation runs. + """ diff --git a/test_samples/proactive/README.md b/test_samples/proactive/README.md new file mode 100644 index 00000000..76fc3771 --- /dev/null +++ b/test_samples/proactive/README.md @@ -0,0 +1,170 @@ +# Proactive Agent Sample + +Demonstrates how`AgentApplication.proactive` enables proactive messaging entirely through the +standard bot messaging endpoint — no separate HTTP trigger endpoint is needed +for in-conversation proactive flows. + +## Patterns shown + +| Pattern | Command / endpoint | Description | +|---------|--------------------|-------------| +| **Store** | `-s` | Persist the current conversation reference | +| **Sign in** | `-signin` | OAuth sign-in via the "me" connection | +| **Sign out** | `-signout` | OAuth sign-out from the "me" connection | +| **Continue (self)** | `-c` | Proactively continue *this* conversation (requires sign-in) | +| **Continue (stored)** | `-c ` | Proactively continue a previously stored conversation | +| **Echo via proactive** | any other message | Echo text back from inside a proactive turn using a custom continuation activity | +| **External notify** | `POST /api/proactive/notify` | Send a one-off notification from outside the bot | + +## Prerequisites + +- Python 3.10+ +- An Azure Bot registration (App ID + Secret) +- An OAuth connection configured in your Azure Bot Service registration +- A tunnel tool such as [dev tunnels](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started) or ngrok +- The [M365 Agents Playground](https://github.com/OfficeDev/microsoft-365-agents-toolkit) or Teams + +## Setup + +```bash +cp env.TEMPLATE .env +# Fill in CLIENT_ID, CLIENT_SECRET, TENANT_ID, and the OAuth connection name +``` + +Install dependencies (from the repo root with the venv active): + +```bash +pip install -r test_samples/proactive/requirements.txt +``` + +Or using editable installs from the repo root: + +```bash +. ./scripts/dev_setup.sh +``` + +## Running + +```bash +python test_samples/proactive/proactive_agent.py +``` + +Listens on `localhost:3978` by default. Set `PORT` in `.env` to override. + +## Bot commands + +All commands go through the standard `POST /api/messages` endpoint. + +| Command | Effect | +|---------|--------| +| `-s` | Store this conversation. Prints the conversation ID. | +| `-signin` | Start the OAuth sign-in flow for the "me" connection. | +| `-signout` | Sign out from the "me" connection. | +| `-c` | Continue *this* conversation proactively (requires sign-in). | +| `-c ` | Continue a stored conversation by its ID (requires sign-in). | +| `/help` | Show usage. | +| anything else | Echo via a proactive turn (demonstrates custom continuation activity). | + +### Walkthrough + +1. Send `-s` → bot replies with the conversation ID. +2. Send `-signin` → complete the OAuth flow. +3. Send `-c` → bot opens a proactive turn on this conversation and reports the token length. +4. Send `-c ` → bot opens a proactive turn on the stored conversation and reports the token length. +5. Send `-signout` → clears the "me" token. +6. Send `-c` again → bot replies "Send **-signin** first." +7. Send any text → bot echoes it back through a proactive turn. + +## HTTP endpoint + +### `POST /api/proactive/notify` + +For **external** triggers (schedulers, webhooks). Requires the conversation to +have been stored with `-s` first. + +```json +{ + "conversationId": "", + "message": "Hello from outside the bot!" +} +``` + +```bash +curl -X POST http://localhost:3978/api/proactive/notify \ + -H "Content-Type: application/json" \ + -d '{"conversationId":"","message":"Hello!"}' +``` + +## How it works + +### `-signin` — OAuth sign-in + +```python +@AGENT_APP.message("-signin", auth_handlers=["ME"]) +async def on_signin(context, state): + await context.send_activity("Signed in.") +``` + +The `auth_handlers=["ME"]` parameter causes the SDK to start or resume the +OAuth flow before the handler runs. By the time the handler is invoked the +user is fully signed in and the token is cached. + +### `-signout` — OAuth sign-out + +```python +@AGENT_APP.message("-signout") +async def on_signout(context, state): + await AGENT_APP.auth.sign_out(context, auth_handler_id="me") + await context.send_activity("Signed out.") +``` + +### `-c` / `-c ` — Continue with sign-in guard + +`token_handlers=["ME"]` is passed to `continue_conversation`. Internally the +SDK calls `_start_or_continue_sign_in` for each listed handler before invoking +the user's handler. If the user is not signed in and +`ProactiveOptions.fail_on_unsigned_in_connections` is `True` (the default), a +`RuntimeError` is raised — mirroring C#'s `UserNotSignedIn` exception. + +```python +try: + await AGENT_APP.proactive.continue_conversation( + ADAPTER, conversation_id, _on_continue, + token_handlers=["ME"], + ) +except RuntimeError: + await context.send_activity("Send **-signin** first.") +``` + +Inside `_on_continue` the token is retrieved via: + +```python +token_response = await AGENT_APP.auth.get_token(context, auth_handler_id="me") +``` + +### Echo via proactive — custom `continuation_activity` + +```python +conversation = Conversation.from_turn_context(context) +continuation = conversation.conversation_reference.get_continuation_activity() +continuation.value = context.activity # carry the original message + +await AGENT_APP.proactive.continue_conversation( + ADAPTER, conversation, _on_echo, + continuation_activity=continuation, +) +``` + +Inside `_on_echo`, `context.activity.value` holds the original `Activity` so +the handler can read `original.text` without shared state. + +## Key classes + +| Class | Module | +|-------|--------| +| `Proactive` | `microsoft_agents.hosting.core.app.proactive` | +| `ProactiveOptions` | `microsoft_agents.hosting.core.app.proactive` | +| `Conversation` | `microsoft_agents.hosting.core.app.proactive` | +| `ConversationBuilder` | `microsoft_agents.hosting.core.app.proactive` | +| `ConversationReferenceBuilder` | `microsoft_agents.hosting.core.app.proactive` | +| `CreateConversationOptions` | `microsoft_agents.hosting.core.app.proactive` | diff --git a/test_samples/proactive/env.TEMPLATE b/test_samples/proactive/env.TEMPLATE new file mode 100644 index 00000000..cc62547e --- /dev/null +++ b/test_samples/proactive/env.TEMPLATE @@ -0,0 +1,11 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID=your-azure-app-client-id +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET=your-azure-app-client-secret +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID=your-tenant-id + +# OAuth connection for the "me" sign-in handler used by -signin / -c +# Create an OAuth connection in your Azure Bot Service registration and put its name here. +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__ME__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME=your-oauth-connection-name +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__ME__SETTINGS__TYPE=UserAuthorization + +# Optional: override the port (default 3978) +# PORT=3978 diff --git a/test_samples/proactive/proactive_agent.py b/test_samples/proactive/proactive_agent.py new file mode 100644 index 00000000..dbd1a04b --- /dev/null +++ b/test_samples/proactive/proactive_agent.py @@ -0,0 +1,359 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Proactive messaging sample. + +Mirrors the pattern from the C# ProactiveAgent sample. + +Bot commands (all via /api/messages): + -s — store this conversation so it can be resumed later. + -c — continue THIS conversation proactively (no stored ID needed). + -c — continue a previously stored conversation by ID. + The continuation requires the user to be signed in via "me". + -signin — sign in with the "me" OAuth connection. + -signout — sign out from the "me" OAuth connection. + /help — show help text. + anything else — echo the message back via a proactive continuation turn, + passing the original activity as the continuation value. + +HTTP endpoints: + POST /api/messages — standard bot channel endpoint (all commands above). + POST /api/proactive/notify — send a one-off notification to a stored conversation. + Body: {"conversationId": "", "message": ""} + GET / — health check. +""" + +from __future__ import annotations + +import json +import logging +import re +from os import environ, path +from typing import Any, Dict + +from aiohttp import web +from dotenv import load_dotenv + +from microsoft_agents.activity import Activity, ActivityTypes, load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import ( + AgentApplication, + MemoryStorage, + MessageFactory, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.app import ApplicationOptions, Authorization, ProactiveOptions +from microsoft_agents.hosting.core.app.proactive import Conversation + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Bootstrap +# --------------------------------------------------------------------------- + +load_dotenv(path.join(path.dirname(__file__), ".env")) +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) + +# Authorization is built from agents_sdk_config so the "me" handler +# defined in the env file is automatically registered. +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + options=ApplicationOptions( + storage=STORAGE, + adapter=ADAPTER, + proactive=ProactiveOptions(), + ), + authorization=AUTHORIZATION, +) + +# --------------------------------------------------------------------------- +# Proactive handlers +# --------------------------------------------------------------------------- + + +async def _on_continue(context: TurnContext, state: TurnState) -> None: + """Proactive turn handler for -c. + + Requires the user to be signed in via the "me" OAuth connection — enforced + by passing token_handlers=["ME"] to continue_conversation. If sign-in is + complete, retrieve the token and report its length (mirrors C# sample). + """ + token_response = await AGENT_APP.auth.get_token(context, auth_handler_id="ME") + token_len = len(token_response.token) if token_response and token_response.token else 0 + await context.send_activity( + f"This is the proactive continuation turn. " + f"Token length = {token_len if token_len else 'not signed in'}." + ) + + +async def _on_echo(context: TurnContext, state: TurnState) -> None: + """Proactive turn handler for the catch-all echo pattern. + + The original activity is carried in context.activity.value so this handler + can reply without touching shared state. + """ + original: Activity = context.activity.value + text = original.text if original and original.text else "(empty)" + await context.send_activity(f"You said: {text}") + + +# --------------------------------------------------------------------------- +# Agent handlers +# --------------------------------------------------------------------------- + + +@AGENT_APP.conversation_update("membersAdded") +async def on_members_added(context: TurnContext, _state: TurnState) -> None: + await context.send_activity( + "Welcome to the Proactive Agent sample!\n\n" + "Commands:\n" + " **-s** — store this conversation for later\n" + " **-c** — proactively continue this conversation (requires sign-in)\n" + " **-c ** — proactively continue a stored conversation\n" + " **-signin** — sign in with the 'me' OAuth connection\n" + " **-signout** — sign out from the 'me' OAuth connection\n" + " **/help** — show this message\n\n" + "Send anything else to see the echo-via-proactive pattern." + ) + + +@AGENT_APP.message("/help") +async def on_help(context: TurnContext, _state: TurnState) -> None: + await context.send_activity( + "Commands:\n" + " **-s** — store this conversation\n" + " **-c** — proactively continue this conversation (requires sign-in)\n" + " **-c ** — proactively continue a stored conversation\n" + " **-signin** — sign in with the 'me' OAuth connection\n" + " **-signout** — sign out from the 'me' OAuth connection\n" + " **/help** — show this message\n\n" + "Anything else is echoed back via a proactive continuation turn.\n\n" + "HTTP: POST /api/proactive/notify to send an external notification." + ) + + +@AGENT_APP.message("-s") +async def on_store(context: TurnContext, _state: TurnState) -> None: + """Store the current conversation so it can be resumed proactively.""" + await AGENT_APP.proactive.store_conversation(context) + conversation_id = context.activity.conversation.id + await context.send_activity( + f"Conversation stored. Use this ID with **-c** or the notify endpoint:\n\n" + f"```\n{conversation_id}\n```" + ) + logger.info("Stored conversation: %s", conversation_id) + + +@AGENT_APP.message("-signin", auth_handlers=["ME"]) +async def on_signin(context: TurnContext, _state: TurnState) -> None: + """Trigger the OAuth sign-in flow for the 'me' connection. + + The auth_handlers=["ME"] parameter causes the SDK to start or resume the + OAuth flow before this handler runs. By the time we reach here the user + is signed in. + """ + await context.send_activity("Signed in.") + + +@AGENT_APP.message("-signout") +async def on_signout(context: TurnContext, state: TurnState) -> None: + """Sign the user out from the 'me' OAuth connection.""" + await AGENT_APP.auth.sign_out(context, auth_handler_id="me") + await context.send_activity("Signed out.") + + +@AGENT_APP.message(re.compile(r"^-c(\s+\S+)?$")) +async def on_continue(context: TurnContext, _state: TurnState) -> None: + """-c [id] — trigger a proactive continuation via the messaging turn. + + With no argument, continues THIS conversation (no prior -s needed). + With an argument, continues the stored conversation with that ID. + + Passes token_handlers=["ME"] so the proactive turn will fail with a + RuntimeError if the user is not yet signed in — mirrors the C# sample's + UserNotSignedIn exception handling. + """ + parts = (context.activity.text or "").split(maxsplit=1) + + if len(parts) == 2: + # -c + conversation_id = parts[1].strip() + try: + await AGENT_APP.proactive.continue_conversation( + ADAPTER, + conversation_id, + _on_continue, + token_handlers=["ME"], + ) + await context.send_activity( + f"Proactive continuation sent to conversation `{conversation_id}`." + ) + except KeyError: + await context.send_activity( + f"Conversation `{conversation_id}` not found. Send **-s** first to store it." + ) + except RuntimeError: + await context.send_activity("Send **-signin** first.") + else: + # -c alone: continue THIS conversation without a storage lookup. + conversation = Conversation.from_turn_context(context) + try: + await AGENT_APP.proactive.continue_conversation( + ADAPTER, + conversation, + _on_continue, + token_handlers=["ME"], + ) + except RuntimeError: + await context.send_activity("Send **-signin** first.") + + +@AGENT_APP.activity(ActivityTypes.message) +async def on_message(context: TurnContext, _state: TurnState) -> None: + """Catch-all: echo the message back via a proactive continuation turn. + + Mirrors C# ProactiveAgent.OnMessageAsync — builds a Conversation from the + current turn context, attaches the original activity as the continuation + value, and lets _on_echo reply from inside the proactive turn. + """ + conversation = Conversation.from_turn_context(context) + + # Attach the original activity as the value on the continuation event so + # _on_echo can read it from context.activity.value without touching state. + continuation = conversation.conversation_reference.get_continuation_activity() + continuation.value = context.activity + + await AGENT_APP.proactive.continue_conversation( + ADAPTER, + conversation, + _on_echo, + continuation_activity=continuation, + ) + + +@AGENT_APP.error +async def on_error(context: TurnContext, error: Exception) -> None: + logger.exception("Unhandled error in AgentApplication: %s", error) + await context.send_activity("An unexpected error occurred. Please try again.") + + +# --------------------------------------------------------------------------- +# HTTP helpers +# --------------------------------------------------------------------------- + + +async def _read_json(request: web.Request) -> Dict[str, Any]: + if request.content_length in (0, None): + return {} + try: + return await request.json() + except json.JSONDecodeError: + return {} + + +# --------------------------------------------------------------------------- +# HTTP route handlers +# --------------------------------------------------------------------------- + + +async def _handle_root(_request: web.Request) -> web.Response: + return web.json_response({"status": "ready", "sample": "proactive-agent"}) + + +async def _handle_messages(request: web.Request) -> web.Response: + agent_app: AgentApplication = request.app["agent_app"] + adapter: CloudAdapter = request.app["adapter"] + response = await start_agent_process(request, agent_app, adapter) + return response or web.Response(status=202) + + +async def _handle_proactive_notify(request: web.Request) -> web.Response: + """Send a one-off proactive activity to a stored conversation. + + Intended for external triggers (schedulers, webhooks, etc.). + For in-conversation proactive messaging use the **-c** command instead. + + Expected JSON body:: + + { + "conversationId": "", + "message": "Hello from the server!" + } + """ + payload = await _read_json(request) + conversation_id: str = (payload.get("conversationId") or "").strip() + message: str = (payload.get("message") or "").strip() + + if not conversation_id: + return web.json_response({"error": "'conversationId' is required."}, status=400) + if not message: + return web.json_response({"error": "'message' is required."}, status=400) + + try: + await AGENT_APP.proactive.send_activity( + ADAPTER, + conversation_id, + MessageFactory.text(f"[Notification] {message}"), + ) + except KeyError: + return web.json_response( + { + "error": ( + f"Conversation '{conversation_id}' not found. " + "Send -s in the conversation first." + ) + }, + status=404, + ) + except Exception as exc: + logger.exception("Error sending proactive notification: %s", exc) + return web.json_response({"error": str(exc)}, status=500) + + logger.info("Notification delivered to: %s", conversation_id) + return web.json_response( + {"status": "delivered", "conversationId": conversation_id}, + status=202, + ) + + +# --------------------------------------------------------------------------- +# App factory +# --------------------------------------------------------------------------- + + +def create_app() -> web.Application: + app = web.Application(middlewares=[jwt_authorization_middleware]) + + app["agent_app"] = AGENT_APP + app["adapter"] = ADAPTER + app["agent_configuration"] = CONNECTION_MANAGER.get_default_connection_configuration() + + app.router.add_get("/", _handle_root) + app.router.add_post("/api/messages", _handle_messages) + app.router.add_post("/api/proactive/notify", _handle_proactive_notify) + + return app + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +if __name__ == "__main__": + host = environ.get("HOST", "localhost") + port = int(environ.get("PORT", "3978")) + + web.run_app(create_app(), host=host, port=port) diff --git a/test_samples/proactive/requirements.txt b/test_samples/proactive/requirements.txt new file mode 100644 index 00000000..5d3543a1 --- /dev/null +++ b/test_samples/proactive/requirements.txt @@ -0,0 +1,6 @@ +microsoft-agents-activity +microsoft-agents-hosting-core +microsoft-agents-authentication-msal +microsoft-agents-hosting-aiohttp +aiohttp +python-dotenv diff --git a/tests/hosting_core/app/proactive/__init__.py b/tests/hosting_core/app/proactive/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/hosting_core/app/proactive/test_conversation.py b/tests/hosting_core/app/proactive/test_conversation.py new file mode 100644 index 00000000..edcffc5d --- /dev/null +++ b/tests/hosting_core/app/proactive/test_conversation.py @@ -0,0 +1,202 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest +from unittest.mock import MagicMock + +from microsoft_agents.activity import ConversationAccount, ConversationReference +from microsoft_agents.hosting.core.app.proactive import Conversation +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + + +def _make_reference( + conversation_id="conv-1", + service_url="https://smba.trafficmanager.net/teams/", + channel_id="msteams", +): + return ConversationReference( + conversation=ConversationAccount(id=conversation_id), + service_url=service_url, + channel_id=channel_id, + ) + + +class TestConversationInit: + def test_init_with_dict_keeps_allowed_claims(self): + claims = { + "aud": "app-id", + "azp": "azp-val", + "appid": "app-id", + "idtyp": "app", + "ver": "2.0", + "iss": "issuer", + "tid": "tenant-id", + } + conv = Conversation(claims=claims, conversation_reference=_make_reference()) + for key in ("aud", "azp", "appid", "idtyp", "ver", "iss", "tid"): + assert conv.claims[key] == claims[key] + + def test_init_with_dict_filters_unknown_claims(self): + claims = {"aud": "app-id", "extra": "should-be-filtered", "another": "drop"} + conv = Conversation(claims=claims, conversation_reference=_make_reference()) + assert "extra" not in conv.claims + assert "another" not in conv.claims + assert conv.claims["aud"] == "app-id" + + def test_init_with_claims_identity_filters_correctly(self): + identity = ClaimsIdentity( + claims={"aud": "app-id", "tid": "tenant", "unrelated": "drop"}, + is_authenticated=True, + ) + conv = Conversation(claims=identity, conversation_reference=_make_reference()) + assert conv.claims["aud"] == "app-id" + assert conv.claims["tid"] == "tenant" + assert "unrelated" not in conv.claims + + def test_init_stores_conversation_reference(self): + ref = _make_reference() + conv = Conversation(claims={}, conversation_reference=ref) + assert conv.conversation_reference is ref + + def test_init_empty_claims_dict(self): + conv = Conversation(claims={}, conversation_reference=_make_reference()) + assert conv.claims == {} + + +class TestConversationFromTurnContext: + def test_from_turn_context_extracts_reference_and_identity(self): + ref = _make_reference("ctx-conv") + identity = ClaimsIdentity(claims={"aud": "app-id", "tid": "t"}, is_authenticated=True) + + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + ctx.turn_state = {ChannelAdapter.AGENT_IDENTITY_KEY: identity} + + conv = Conversation.from_turn_context(ctx) + + assert conv.conversation_reference is ref + assert conv.claims.get("aud") == "app-id" + assert conv.claims.get("tid") == "t" + + def test_from_turn_context_handles_missing_identity(self): + ref = _make_reference("ctx-conv") + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + ctx.turn_state = {} + + conv = Conversation.from_turn_context(ctx) + + assert conv.conversation_reference is ref + assert conv.claims == {} + + +class TestConversationClaimsHelpers: + def test_claims_from_identity_keeps_allowed_keys(self): + identity = ClaimsIdentity( + claims={"aud": "a", "tid": "t", "ver": "2.0", "other": "drop"}, + is_authenticated=True, + ) + result = Conversation.claims_from_identity(identity) + assert result == {"aud": "a", "tid": "t", "ver": "2.0"} + + def test_claims_from_identity_empty_claims(self): + identity = ClaimsIdentity(claims={}, is_authenticated=True) + result = Conversation.claims_from_identity(identity) + assert result == {} + + def test_identity_from_claims_is_authenticated(self): + claims = {"aud": "app-id", "tid": "tenant"} + identity = Conversation.identity_from_claims(claims) + assert identity.is_authenticated is True + + def test_identity_from_claims_preserves_values(self): + claims = {"aud": "app-id", "tid": "tenant", "ver": "2.0"} + identity = Conversation.identity_from_claims(claims) + assert identity.claims["aud"] == "app-id" + assert identity.claims["tid"] == "tenant" + assert identity.claims["ver"] == "2.0" + + def test_identity_from_claims_does_not_mutate_input(self): + claims = {"aud": "app-id"} + Conversation.identity_from_claims(claims) + assert claims == {"aud": "app-id"} + + +class TestConversationValidate: + def test_validate_ok(self): + conv = Conversation(claims={}, conversation_reference=_make_reference()) + conv.validate() # must not raise + + def test_validate_missing_conversation_reference_raises(self): + conv = Conversation(claims={}, conversation_reference=_make_reference()) + conv.conversation_reference = None + with pytest.raises(ValueError, match="conversation_reference"): + conv.validate() + + def test_validate_missing_conversation_account_raises(self): + ref = _make_reference() + conv = Conversation(claims={}, conversation_reference=ref) + # ConversationReference.conversation is a required Pydantic field so it cannot + # be omitted at construction time. Set it to None after construction to exercise + # the Conversation.validate() guard directly. + conv.conversation_reference.conversation = None + with pytest.raises(ValueError, match="conversation"): + conv.validate() + + def test_validate_missing_service_url_raises(self): + ref = ConversationReference( + conversation=ConversationAccount(id="conv1"), + channel_id="msteams", + ) + conv = Conversation(claims={}, conversation_reference=ref) + with pytest.raises(ValueError, match="service_url"): + conv.validate() + + +class TestConversationSerialization: + def test_store_item_to_json_contains_claims(self): + conv = Conversation( + claims={"aud": "app-id", "tid": "tenant"}, + conversation_reference=_make_reference(), + ) + data = conv.store_item_to_json() + assert data["claims"]["aud"] == "app-id" + assert data["claims"]["tid"] == "tenant" + + def test_store_item_to_json_contains_conversation_reference(self): + conv = Conversation( + claims={"aud": "app-id"}, + conversation_reference=_make_reference("my-conv"), + ) + data = conv.store_item_to_json() + assert "conversation_reference" in data + + def test_round_trip_preserves_claims(self): + original = Conversation( + claims={"aud": "app-id", "tid": "tenant"}, + conversation_reference=_make_reference("rt-conv"), + ) + json_data = original.store_item_to_json() + restored = Conversation.from_json_to_store_item(json_data) + assert restored.claims == {"aud": "app-id", "tid": "tenant"} + + def test_round_trip_preserves_conversation_id(self): + original = Conversation( + claims={}, + conversation_reference=_make_reference("rt-conv-id"), + ) + json_data = original.store_item_to_json() + restored = Conversation.from_json_to_store_item(json_data) + assert restored.conversation_reference.conversation.id == "rt-conv-id" + + def test_round_trip_preserves_service_url(self): + original = Conversation( + claims={}, + conversation_reference=_make_reference(service_url="https://custom.service/"), + ) + json_data = original.store_item_to_json() + restored = Conversation.from_json_to_store_item(json_data) + assert restored.conversation_reference.service_url == "https://custom.service/" diff --git a/tests/hosting_core/app/proactive/test_conversation_builder.py b/tests/hosting_core/app/proactive/test_conversation_builder.py new file mode 100644 index 00000000..479c9216 --- /dev/null +++ b/tests/hosting_core/app/proactive/test_conversation_builder.py @@ -0,0 +1,290 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest + +from microsoft_agents.hosting.core.app.proactive import Conversation, ConversationBuilder +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +# --------------------------------------------------------------------------- +# Helper +# --------------------------------------------------------------------------- + + +def _prep_build(builder: ConversationBuilder) -> ConversationBuilder: + """Make a ConversationBuilder ready to call .build() without Pydantic errors. + + The implementation passes _agent_name and _conversation_id directly to + Pydantic models that reject None / empty-string values. There is no public + API to set these on the builder, so tests set them directly. + """ + if builder._agent_id and builder._agent_name is None: + builder._agent_name = "Agent" + if not builder._conversation_id: + builder._conversation_id = "conv-1" + return builder + + +# --------------------------------------------------------------------------- +# create() +# --------------------------------------------------------------------------- + + +class TestConversationBuilderCreate: + def test_create_sets_aud_claim(self): + builder = ConversationBuilder.create("my-app-id", "msteams") + assert builder._claims["aud"] == "my-app-id" + + def test_create_sets_channel_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert builder._channel_id == "msteams" + + def test_create_teams_prefixes_agent_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert builder._agent_id == "28:app-id" + + def test_create_non_teams_no_prefix(self): + builder = ConversationBuilder.create("app-id", "directline") + assert builder._agent_id == "app-id" + + def test_create_with_requestor_id_sets_appid_claim(self): + builder = ConversationBuilder.create("app-id", "msteams", requestor_id="requestor-id") + assert builder._claims["appid"] == "requestor-id" + + def test_create_without_requestor_id_no_appid_claim(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert "appid" not in builder._claims + + def test_create_custom_service_url(self): + builder = ConversationBuilder.create( + "app-id", "msteams", service_url="https://custom.service/" + ) + assert builder._service_url == "https://custom.service/" + + def test_create_default_service_url_teams(self): + builder = ConversationBuilder.create("app-id", "msteams") + assert builder._service_url == "https://smba.trafficmanager.net/teams/" + + def test_create_default_service_url_generic(self): + builder = ConversationBuilder.create("app-id", "directline") + assert builder._service_url == "https://directline.botframework.com/" + + def test_create_returns_builder_instance(self): + result = ConversationBuilder.create("app-id", "msteams") + assert isinstance(result, ConversationBuilder) + + +# --------------------------------------------------------------------------- +# create_from_identity() +# --------------------------------------------------------------------------- + + +class TestConversationBuilderCreateFromIdentity: + def test_create_from_identity_sets_channel_id(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._channel_id == "msteams" + + def test_create_from_identity_filters_claims(self): + identity = ClaimsIdentity( + claims={"aud": "app-id", "tid": "tenant", "unrelated": "drop"}, + is_authenticated=True, + ) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._claims["aud"] == "app-id" + assert builder._claims["tid"] == "tenant" + assert "unrelated" not in builder._claims + + def test_create_from_identity_teams_prefixes_agent(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._agent_id == "28:app-id" + + def test_create_from_identity_non_teams_no_prefix(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "directline") + assert builder._agent_id == "app-id" + + def test_create_from_identity_no_app_id_no_agent(self): + identity = ClaimsIdentity(claims={}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity(identity, "msteams") + assert builder._agent_id is None + + def test_create_from_identity_custom_service_url(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + builder = ConversationBuilder.create_from_identity( + identity, "msteams", service_url="https://override/" + ) + assert builder._service_url == "https://override/" + + def test_create_from_identity_returns_builder_instance(self): + identity = ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + result = ConversationBuilder.create_from_identity(identity, "msteams") + assert isinstance(result, ConversationBuilder) + + +# --------------------------------------------------------------------------- +# Fluent setters +# --------------------------------------------------------------------------- + + +class TestConversationBuilderSetters: + def test_with_user_sets_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_user("user-id") + assert builder._user_id == "user-id" + + def test_with_user_sets_name(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_user("user-id", "Alice") + assert builder._user_name == "Alice" + + def test_with_user_returns_self(self): + builder = ConversationBuilder.create("app-id", "msteams") + result = builder.with_user("user-id") + assert result is builder + + def test_with_user_name_optional(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_user("user-id") + assert builder._user_name is None + + def test_with_conversation_sets_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_conversation("conv-123") + assert builder._conversation_id == "conv-123" + + def test_with_conversation_sets_tenant_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_conversation("conv-123", tenant_id="tenant-abc") + assert builder._tenant_id == "tenant-abc" + + def test_with_conversation_sets_name(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_conversation("conv-123", conversation_name="My Chat") + assert builder._conversation_name == "My Chat" + + def test_with_conversation_returns_self(self): + builder = ConversationBuilder.create("app-id", "msteams") + result = builder.with_conversation("conv-123") + assert result is builder + + def test_with_activity_id_sets_id(self): + builder = ConversationBuilder.create("app-id", "msteams") + builder.with_activity_id("act-456") + assert builder._activity_id == "act-456" + + def test_with_activity_id_returns_self(self): + builder = ConversationBuilder.create("app-id", "msteams") + result = builder.with_activity_id("act-456") + assert result is builder + + +# --------------------------------------------------------------------------- +# build() +# --------------------------------------------------------------------------- + + +class TestConversationBuilderBuild: + def test_build_sets_channel_id_on_reference(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.conversation_reference.channel_id == "msteams" + + def test_build_sets_aud_claim(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.claims.get("aud") == "app-id" + + def test_build_sets_service_url(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.conversation_reference.service_url == "https://smba.trafficmanager.net/teams/" + + def test_build_sets_agent_with_teams_prefix(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert conv.conversation_reference.agent.id == "28:app-id" + + def test_build_no_agent_when_id_none(self): + # When _agent_id is not set, build() passes bot=None to ConversationReference. + # ConversationReference.agent has Field(None, alias="bot") with ChannelAccount type, + # so explicitly passing bot=None raises a Pydantic ValidationError. + from pydantic import ValidationError + builder = ConversationBuilder() + builder._channel_id = "directline" + builder._service_url = "https://directline.botframework.com/" + builder._conversation_id = "conv-placeholder" + with pytest.raises(ValidationError): + builder.build() + + def test_build_sets_user(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_user("user-oid", "Alice") + .build() + ) + assert conv.conversation_reference.user.id == "user-oid" + assert conv.conversation_reference.user.name == "Alice" + + def test_build_sets_conversation_id(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_conversation("19:thread@thread.v2") + .build() + ) + assert conv.conversation_reference.conversation.id == "19:thread@thread.v2" + + def test_build_sets_conversation_tenant_id(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_conversation("conv-1", tenant_id="tenant-xyz") + .build() + ) + assert conv.conversation_reference.conversation.tenant_id == "tenant-xyz" + + def test_build_sets_activity_id(self): + conv = ( + _prep_build(ConversationBuilder.create("app-id", "msteams")) + .with_activity_id("act-1") + .build() + ) + assert conv.conversation_reference.activity_id == "act-1" + + def test_build_requires_channel_id(self): + builder = ConversationBuilder() + with pytest.raises(ValueError): + builder.build() + + def test_build_with_identity_preserves_claims(self): + identity = ClaimsIdentity( + claims={"aud": "app-id", "tid": "tenant", "ver": "2.0"}, + is_authenticated=True, + ) + conv = _prep_build( + ConversationBuilder.create_from_identity(identity, "msteams") + ).build() + assert conv.claims["aud"] == "app-id" + assert conv.claims["tid"] == "tenant" + assert conv.claims["ver"] == "2.0" + + def test_build_conversation_is_conversation_instance(self): + conv = _prep_build(ConversationBuilder.create("app-id", "msteams")).build() + assert isinstance(conv, Conversation) + + def test_fluent_chaining_full_build(self): + conv = ( + _prep_build( + ConversationBuilder.create("app-id", "msteams", requestor_id="req-id") + ) + .with_user("user-oid", "Bob") + .with_conversation("19:thread@thread.v2", tenant_id="tenant-1") + .with_activity_id("act-xyz") + .build() + ) + ref = conv.conversation_reference + assert conv.claims["aud"] == "app-id" + assert conv.claims["appid"] == "req-id" + assert ref.user.id == "user-oid" + assert ref.conversation.id == "19:thread@thread.v2" + assert ref.activity_id == "act-xyz" + assert ref.agent.id == "28:app-id" diff --git a/tests/hosting_core/app/proactive/test_conversation_reference_builder.py b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py new file mode 100644 index 00000000..20289cf3 --- /dev/null +++ b/tests/hosting_core/app/proactive/test_conversation_reference_builder.py @@ -0,0 +1,253 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest + +from microsoft_agents.hosting.core.app.proactive import ConversationReferenceBuilder +from microsoft_agents.hosting.core.app.proactive.conversation_reference_builder import ( + _service_url_for_channel, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _buildable(channel_id="msteams", conv_id="conv-1"): + """Return a builder that can call .build() without Pydantic errors. + + The implementation passes name explicitly to ChannelAccount, so an agent + with a name must always be present before calling .build(). + """ + return ( + ConversationReferenceBuilder.create(channel_id, conv_id) + .with_agent("28:app-id", "Bot") + ) + + +# --------------------------------------------------------------------------- +# _service_url_for_channel +# --------------------------------------------------------------------------- + + +class TestServiceUrlForChannel: + def test_teams_returns_smba_url(self): + assert _service_url_for_channel("msteams") == "https://smba.trafficmanager.net/teams/" + + def test_directline_returns_generic_url(self): + assert _service_url_for_channel("directline") == "https://directline.botframework.com/" + + def test_webchat_returns_generic_url(self): + assert _service_url_for_channel("webchat") == "https://webchat.botframework.com/" + + def test_unknown_channel_uses_pattern(self): + assert _service_url_for_channel("mychannel") == "https://mychannel.botframework.com/" + + +# --------------------------------------------------------------------------- +# create() +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderCreate: + def test_create_sets_channel_id(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-123") + assert builder._channel_id == "msteams" + + def test_create_sets_conversation_id(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-123") + assert builder._conversation_id == "conv-123" + + def test_create_returns_builder_instance(self): + result = ConversationReferenceBuilder.create("msteams", "conv-1") + assert isinstance(result, ConversationReferenceBuilder) + + +# --------------------------------------------------------------------------- +# create_for_agent() +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderCreateForAgent: + def test_create_for_agent_teams_prefixes_id(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + assert builder._agent_id == "28:app-id" + + def test_create_for_agent_non_teams_no_prefix(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "directline") + assert builder._agent_id == "app-id" + + def test_create_for_agent_default_service_url_teams(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + assert builder._service_url == "https://smba.trafficmanager.net/teams/" + + def test_create_for_agent_custom_service_url(self): + builder = ConversationReferenceBuilder.create_for_agent( + "app-id", "msteams", service_url="https://custom.url/" + ) + assert builder._service_url == "https://custom.url/" + + def test_create_for_agent_returns_builder_instance(self): + result = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + assert isinstance(result, ConversationReferenceBuilder) + + +# --------------------------------------------------------------------------- +# Fluent setters +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderSetters: + def test_with_agent_sets_id_and_name(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_agent("agent-id", "My Agent") + assert builder._agent_id == "agent-id" + assert builder._agent_name == "My Agent" + + def test_with_agent_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_agent("agent-id", "Agent") + assert result is builder + + def test_with_agent_name_optional(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_agent("agent-id") + assert builder._agent_id == "agent-id" + assert builder._agent_name is None + + def test_with_user_sets_id_and_name(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_user("user-id", "Alice") + assert builder._user_id == "user-id" + assert builder._user_name == "Alice" + + def test_with_user_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_user("user-id", "Alice") + assert result is builder + + def test_with_service_url_sets_url(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_service_url("https://override/") + assert builder._service_url == "https://override/" + + def test_with_service_url_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_service_url("https://override/") + assert result is builder + + def test_with_activity_id_sets_id(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_activity_id("act-123") + assert builder._activity_id == "act-123" + + def test_with_activity_id_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_activity_id("act-123") + assert result is builder + + def test_with_locale_sets_locale(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + builder.with_locale("en-US") + assert builder._locale == "en-US" + + def test_with_locale_returns_self(self): + builder = ConversationReferenceBuilder.create("msteams", "conv-1") + result = builder.with_locale("fr-FR") + assert result is builder + + +# --------------------------------------------------------------------------- +# build() +# --------------------------------------------------------------------------- + + +class TestConversationReferenceBuilderBuild: + def test_build_sets_channel_id(self): + ref = _buildable().build() + assert ref.channel_id == "msteams" + + def test_build_sets_conversation_id(self): + ref = _buildable(conv_id="conv-abc").build() + assert ref.conversation.id == "conv-abc" + + def test_build_default_service_url_teams(self): + ref = _buildable("msteams").build() + assert ref.service_url == "https://smba.trafficmanager.net/teams/" + + def test_build_default_service_url_generic(self): + ref = _buildable("directline").build() + assert ref.service_url == "https://directline.botframework.com/" + + def test_build_respects_explicit_service_url(self): + ref = ( + _buildable() + .with_service_url("https://custom/") + .build() + ) + assert ref.service_url == "https://custom/" + + def test_build_sets_agent_account(self): + ref = ( + ConversationReferenceBuilder.create("msteams", "conv-1") + .with_agent("28:app-id", "My Bot") + .build() + ) + assert ref.agent.id == "28:app-id" + assert ref.agent.name == "My Bot" + + def test_build_sets_user_account(self): + ref = ( + _buildable() + .with_user("user-oid", "Alice") + .build() + ) + assert ref.user.id == "user-oid" + assert ref.user.name == "Alice" + + def test_build_sets_activity_id(self): + ref = _buildable().with_activity_id("act-xyz").build() + assert ref.activity_id == "act-xyz" + + def test_build_sets_locale(self): + ref = _buildable().with_locale("en-GB").build() + assert ref.locale == "en-GB" + + def test_build_no_user_when_not_set(self): + ref = _buildable().build() + assert ref.user is None + + def test_build_requires_channel_id(self): + builder = ConversationReferenceBuilder() + with pytest.raises(ValueError): + builder.build() + + def test_build_teams_prefix_from_create_for_agent(self): + builder = ConversationReferenceBuilder.create_for_agent("app-id", "msteams") + # Provide name: implementation passes it explicitly to ChannelAccount (rejects None) + builder._agent_name = "Bot" + # Provide conversation_id: create_for_agent doesn't set it; ConversationAccount.id + # is NonEmptyString so the "" fallback in build() raises a Pydantic error + builder._conversation_id = "conv-1" + ref = builder.build() + assert ref.agent.id == "28:app-id" + + def test_fluent_chaining_all_methods(self): + ref = ( + ConversationReferenceBuilder.create("msteams", "conv-1") + .with_agent("28:app-id", "Bot") + .with_user("user-id", "Bob") + .with_locale("de-DE") + .with_activity_id("act-99") + .with_service_url("https://override.url/") + .build() + ) + assert ref.channel_id == "msteams" + assert ref.agent.id == "28:app-id" + assert ref.user.id == "user-id" + assert ref.locale == "de-DE" + assert ref.activity_id == "act-99" + assert ref.service_url == "https://override.url/" diff --git a/tests/hosting_core/app/proactive/test_create_conversation_options.py b/tests/hosting_core/app/proactive/test_create_conversation_options.py new file mode 100644 index 00000000..3b7d43e2 --- /dev/null +++ b/tests/hosting_core/app/proactive/test_create_conversation_options.py @@ -0,0 +1,117 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest + +from microsoft_agents.activity import ConversationParameters +from microsoft_agents.hosting.core.app.proactive import CreateConversationOptions +from microsoft_agents.hosting.core.authorization import ClaimsIdentity + + +def _make_identity(): + return ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + + +def _make_params(): + return ConversationParameters() + + +class TestCreateConversationOptionsDefaults: + def test_default_identity_is_none(self): + opts = CreateConversationOptions() + assert opts.identity is None + + def test_default_channel_id_is_empty(self): + opts = CreateConversationOptions() + assert opts.channel_id == "" + + def test_default_parameters_is_none(self): + opts = CreateConversationOptions() + assert opts.parameters is None + + def test_default_service_url_is_none(self): + opts = CreateConversationOptions() + assert opts.service_url is None + + def test_default_audience_is_none(self): + opts = CreateConversationOptions() + assert opts.audience is None + + def test_default_store_conversation_is_false(self): + opts = CreateConversationOptions() + assert opts.store_conversation is False + + +class TestCreateConversationOptionsAssignment: + def test_identity_assigned(self): + identity = _make_identity() + opts = CreateConversationOptions(identity=identity) + assert opts.identity is identity + + def test_channel_id_assigned(self): + opts = CreateConversationOptions(channel_id="msteams") + assert opts.channel_id == "msteams" + + def test_parameters_assigned(self): + params = _make_params() + opts = CreateConversationOptions(parameters=params) + assert opts.parameters is params + + def test_service_url_assigned(self): + opts = CreateConversationOptions(service_url="https://custom/") + assert opts.service_url == "https://custom/" + + def test_audience_assigned(self): + opts = CreateConversationOptions(audience="https://api.botframework.com") + assert opts.audience == "https://api.botframework.com" + + def test_store_conversation_assigned(self): + opts = CreateConversationOptions(store_conversation=True) + assert opts.store_conversation is True + + +class TestCreateConversationOptionsValidate: + def test_validate_passes_with_required_fields(self): + opts = CreateConversationOptions( + identity=_make_identity(), + channel_id="msteams", + parameters=_make_params(), + service_url="https://custom/", + ) + opts.validate() # must not raise + + def test_validate_optional_fields_not_required(self): + opts = CreateConversationOptions( + identity=_make_identity(), + channel_id="msteams", + parameters=_make_params(), + service_url="https://custom/", + audience=None, + ) + opts.validate() # must not raise + + def test_validate_raises_when_identity_missing(self): + opts = CreateConversationOptions(channel_id="msteams", parameters=_make_params()) + with pytest.raises(ValueError, match="identity"): + opts.validate() + + def test_validate_raises_when_channel_id_empty(self): + opts = CreateConversationOptions( + identity=_make_identity(), parameters=_make_params() + ) + with pytest.raises(ValueError, match="channel_id"): + opts.validate() + + def test_validate_raises_when_parameters_missing(self): + opts = CreateConversationOptions( + identity=_make_identity(), channel_id="msteams" + ) + with pytest.raises(ValueError, match="parameters"): + opts.validate() + + def test_validate_raises_when_all_missing(self): + opts = CreateConversationOptions() + with pytest.raises(ValueError): + opts.validate() diff --git a/tests/hosting_core/app/proactive/test_proactive.py b/tests/hosting_core/app/proactive/test_proactive.py new file mode 100644 index 00000000..40264a3e --- /dev/null +++ b/tests/hosting_core/app/proactive/test_proactive.py @@ -0,0 +1,585 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from microsoft_agents.activity import ( + Activity, + ActivityTypes, + ChannelAccount, + ConversationAccount, + ConversationParameters, + ConversationReference, + ResourceResponse, +) +from microsoft_agents.hosting.core import MemoryStorage +from microsoft_agents.hosting.core.turn_context import TurnContext +from microsoft_agents.hosting.core.app.proactive import ( + Conversation, + CreateConversationOptions, + Proactive, + ProactiveOptions, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.channel_adapter import ChannelAdapter + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_reference( + conversation_id="conv-1", + service_url="https://smba.trafficmanager.net/teams/", + channel_id="msteams", +): + # bot and user must be present: get_continuation_activity() passes them directly + # to Activity(recipient=..., from_property=...) which rejects explicit None values. + return ConversationReference( + conversation=ConversationAccount(id=conversation_id), + service_url=service_url, + channel_id=channel_id, + bot=ChannelAccount(id="28:bot-id"), + user=ChannelAccount(id="user-id"), + ) + + +def _make_conversation(conversation_id="conv-1"): + return Conversation( + claims={"aud": "app-id", "tid": "tenant"}, + conversation_reference=_make_reference(conversation_id), + ) + + +def _make_app(storage=None): + app = MagicMock() + app.options.storage = storage + app._turn_state_factory = None + app._auth = None + return app + + +def _make_mock_state(): + state = MagicMock() + state.save = AsyncMock() + state.load = AsyncMock() + return state + + +# --------------------------------------------------------------------------- +# Storage property +# --------------------------------------------------------------------------- + + +class TestProactiveStorageProperty: + def test_storage_resolved_from_proactive_options(self): + storage = MemoryStorage() + app = _make_app(storage=None) + opts = ProactiveOptions(storage=storage) + proactive = Proactive(app, opts) + assert proactive._storage is storage + + def test_storage_resolved_from_app_options(self): + storage = MemoryStorage() + app = _make_app(storage=storage) + opts = ProactiveOptions() + proactive = Proactive(app, opts) + assert proactive._storage is storage + + def test_proactive_options_storage_takes_precedence(self): + storage_app = MemoryStorage() + storage_opts = MemoryStorage() + app = _make_app(storage=storage_app) + opts = ProactiveOptions(storage=storage_opts) + proactive = Proactive(app, opts) + assert proactive._storage is storage_opts + + def test_storage_raises_runtime_error_when_missing(self): + app = _make_app(storage=None) + opts = ProactiveOptions() + proactive = Proactive(app, opts) + with pytest.raises(RuntimeError): + _ = proactive._storage + + +class TestProactiveStorageKey: + def test_storage_key_format(self): + key = Proactive._storage_key("conv-123") + assert key == "proactive/conversations/conv-123" + + def test_storage_key_includes_conversation_id(self): + key = Proactive._storage_key("my-unique-id") + assert "my-unique-id" in key + + +# --------------------------------------------------------------------------- +# Store / Get / Delete conversations +# --------------------------------------------------------------------------- + + +class TestProactiveStoreConversation: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.mark.asyncio + async def test_store_then_get_returns_conversation(self, proactive): + conv = _make_conversation("store-get") + await proactive.store_conversation(conv) + result = await proactive.get_conversation("store-get") + assert result is not None + assert result.conversation_reference.conversation.id == "store-get" + + @pytest.mark.asyncio + async def test_store_preserves_claims(self, proactive): + conv = _make_conversation("claims-test") + await proactive.store_conversation(conv) + result = await proactive.get_conversation("claims-test") + assert result.claims.get("aud") == "app-id" + assert result.claims.get("tid") == "tenant" + + @pytest.mark.asyncio + async def test_store_overwrites_existing_conversation(self, proactive): + await proactive.store_conversation(_make_conversation("overwrite-me")) + new_conv = Conversation( + claims={"aud": "new-app-id"}, + conversation_reference=_make_reference("overwrite-me"), + ) + await proactive.store_conversation(new_conv) + result = await proactive.get_conversation("overwrite-me") + assert result.claims.get("aud") == "new-app-id" + + @pytest.mark.asyncio + async def test_store_from_turn_context(self, proactive): + ref = _make_reference("ctx-conv") + identity = ClaimsIdentity(claims={"aud": "ctx-app"}, is_authenticated=True) + + # spec=TurnContext is required: store_conversation checks isinstance(ctx, TurnContext) + ctx = MagicMock(spec=TurnContext) + ctx.activity = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + ctx.turn_state = {ChannelAdapter.AGENT_IDENTITY_KEY: identity} + + await proactive.store_conversation(ctx) + result = await proactive.get_conversation("ctx-conv") + assert result is not None + assert result.claims.get("aud") == "ctx-app" + + +class TestProactiveGetConversation: + @pytest.fixture + def proactive(self): + storage = MemoryStorage() + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.mark.asyncio + async def test_get_returns_none_when_not_found(self, proactive): + result = await proactive.get_conversation("nonexistent") + assert result is None + + @pytest.mark.asyncio + async def test_get_returns_none_after_delete(self, proactive): + await proactive.store_conversation(_make_conversation("to-delete")) + await proactive.delete_conversation("to-delete") + result = await proactive.get_conversation("to-delete") + assert result is None + + +class TestProactiveDeleteConversation: + @pytest.fixture + def proactive(self): + storage = MemoryStorage() + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.mark.asyncio + async def test_delete_removes_stored_conversation(self, proactive): + await proactive.store_conversation(_make_conversation("del-conv")) + await proactive.delete_conversation("del-conv") + assert await proactive.get_conversation("del-conv") is None + + @pytest.mark.asyncio + async def test_delete_nonexistent_does_not_raise(self, proactive): + await proactive.delete_conversation("not-stored") # must not raise + + @pytest.mark.asyncio + async def test_delete_only_removes_target(self, proactive): + await proactive.store_conversation(_make_conversation("keep-me")) + await proactive.store_conversation(_make_conversation("remove-me")) + await proactive.delete_conversation("remove-me") + assert await proactive.get_conversation("keep-me") is not None + assert await proactive.get_conversation("remove-me") is None + + +# --------------------------------------------------------------------------- +# Send activity +# --------------------------------------------------------------------------- + + +class TestProactiveSendActivity: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + def _make_adapter(self, response_id="resp-1"): + adapter = MagicMock() + + async def fake_continue(claims, continuation, callback): + ctx = MagicMock() + ctx.send_activity = AsyncMock(return_value=ResourceResponse(id=response_id)) + await callback(ctx) + + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + return adapter + + @pytest.mark.asyncio + async def test_send_activity_with_conversation_object(self, proactive): + conv = _make_conversation("send-obj") + adapter = self._make_adapter() + activity = Activity(type=ActivityTypes.message, text="Hello!") + await proactive.send_activity(adapter, conv, activity) + adapter.continue_conversation_with_claims.assert_called_once() + + @pytest.mark.asyncio + async def test_send_activity_with_conversation_id(self, proactive): + conv = _make_conversation("send-id") + await proactive.store_conversation(conv) + adapter = self._make_adapter() + activity = Activity(type=ActivityTypes.message, text="Notify!") + await proactive.send_activity(adapter, "send-id", activity) + adapter.continue_conversation_with_claims.assert_called_once() + + @pytest.mark.asyncio + async def test_send_activity_raises_for_missing_conversation(self, proactive): + adapter = self._make_adapter() + activity = Activity(type=ActivityTypes.message, text="Hello") + with pytest.raises(KeyError): + await proactive.send_activity(adapter, "no-such-conv", activity) + + @pytest.mark.asyncio + async def test_send_activity_passes_activity_to_adapter(self, proactive): + conv = _make_conversation("pass-activity") + sent_activity = None + + async def fake_continue(claims, continuation, callback): + nonlocal sent_activity + ctx = MagicMock() + + async def capture_send(act): + nonlocal sent_activity + sent_activity = act + return ResourceResponse(id="r") + + ctx.send_activity = capture_send + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + activity = Activity(type=ActivityTypes.message, text="Specific text") + await proactive.send_activity(adapter, conv, activity) + assert sent_activity is activity + + @pytest.mark.asyncio + async def test_send_activity_propagates_exception_from_callback(self, proactive): + conv = _make_conversation("exc-conv") + + async def fake_continue(claims, continuation, callback): + ctx = MagicMock() + ctx.send_activity = AsyncMock(side_effect=RuntimeError("channel error")) + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + with pytest.raises(RuntimeError, match="channel error"): + await proactive.send_activity( + adapter, conv, Activity(type=ActivityTypes.message) + ) + + +# --------------------------------------------------------------------------- +# Continue conversation +# --------------------------------------------------------------------------- + + +class TestProactiveContinueConversation: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + def _make_adapter(self, proactive_instance, state=None): + state = state or _make_mock_state() + + async def fake_continue(claims, continuation, callback): + ctx = MagicMock() + with patch.object(proactive_instance, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + return adapter, state + + @pytest.mark.asyncio + async def test_continue_invokes_handler(self, proactive): + conv = _make_conversation("cont-invoke") + handler_called = False + + async def handler(ctx, state): + nonlocal handler_called + handler_called = True + + adapter, _ = self._make_adapter(proactive) + await proactive.continue_conversation(adapter, conv, handler) + assert handler_called + + @pytest.mark.asyncio + async def test_continue_with_conversation_id(self, proactive): + conv = _make_conversation("cont-by-id") + await proactive.store_conversation(conv) + + handler_called = False + + async def handler(ctx, state): + nonlocal handler_called + handler_called = True + + adapter, _ = self._make_adapter(proactive) + await proactive.continue_conversation(adapter, "cont-by-id", handler) + assert handler_called + + @pytest.mark.asyncio + async def test_continue_raises_key_error_for_missing(self, proactive): + async def handler(ctx, state): + pass + + adapter = MagicMock() + with pytest.raises(KeyError): + await proactive.continue_conversation(adapter, "not-in-storage", handler) + + @pytest.mark.asyncio + async def test_continue_uses_default_continuation_activity(self, proactive): + conv = _make_conversation("default-act") + captured_continuation = None + + async def fake_continue(claims, continuation, callback): + nonlocal captured_continuation + captured_continuation = continuation + state = _make_mock_state() + ctx = MagicMock() + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + + async def handler(ctx, state): + pass + + await proactive.continue_conversation(adapter, conv, handler) + default_act = conv.conversation_reference.get_continuation_activity() + assert captured_continuation.type == default_act.type + + @pytest.mark.asyncio + async def test_continue_uses_custom_continuation_activity(self, proactive): + conv = _make_conversation("custom-act") + custom_activity = Activity(type=ActivityTypes.event, name="custom.event") + captured_continuation = None + + async def fake_continue(claims, continuation, callback): + nonlocal captured_continuation + captured_continuation = continuation + state = _make_mock_state() + ctx = MagicMock() + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + + async def handler(ctx, state): + pass + + await proactive.continue_conversation( + adapter, conv, handler, continuation_activity=custom_activity + ) + assert captured_continuation is custom_activity + + @pytest.mark.asyncio + async def test_continue_calls_adapter_with_correct_claims(self, proactive): + conv = _make_conversation("claims-check") + captured_claims = None + + async def fake_continue(claims, continuation, callback): + nonlocal captured_claims + captured_claims = claims + state = _make_mock_state() + ctx = MagicMock() + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await callback(ctx) + + adapter = MagicMock() + adapter.continue_conversation_with_claims = AsyncMock(side_effect=fake_continue) + + async def handler(ctx, state): + pass + + await proactive.continue_conversation(adapter, conv, handler) + assert captured_claims is not None + assert captured_claims.claims.get("aud") == "app-id" + + @pytest.mark.asyncio + async def test_continue_propagates_exception_from_handler(self, proactive): + conv = _make_conversation("exc-handler") + + async def bad_handler(ctx, state): + raise ValueError("handler error") + + adapter, _ = self._make_adapter(proactive) + with pytest.raises(ValueError, match="handler error"): + await proactive.continue_conversation(adapter, conv, bad_handler) + + +# --------------------------------------------------------------------------- +# Create conversation +# --------------------------------------------------------------------------- + + +class TestProactiveCreateConversation: + @pytest.fixture + def storage(self): + return MemoryStorage() + + @pytest.fixture + def proactive(self, storage): + return Proactive(_make_app(storage=storage), ProactiveOptions()) + + @pytest.fixture + def identity(self): + return ClaimsIdentity(claims={"aud": "app-id"}, is_authenticated=True) + + @pytest.fixture + def options(self, identity): + return CreateConversationOptions( + identity=identity, + channel_id="msteams", + parameters=ConversationParameters(), + service_url="https://smba.trafficmanager.net/teams/", + ) + + def _make_adapter(self, new_conversation_id="new-conv"): + ref = _make_reference(new_conversation_id) + + async def fake_create(app_id, channel_id, service_url, audience, params, callback): + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + await callback(ctx) + + adapter = MagicMock() + adapter.create_conversation = AsyncMock(side_effect=fake_create) + return adapter + + @pytest.mark.asyncio + async def test_create_returns_conversation(self, proactive, options): + adapter = self._make_adapter("new-conv-1") + result = await proactive.create_conversation(adapter, options) + assert result is not None + assert isinstance(result, Conversation) + + @pytest.mark.asyncio + async def test_create_sets_conversation_id(self, proactive, options): + adapter = self._make_adapter("created-id") + result = await proactive.create_conversation(adapter, options) + assert result.conversation_reference.conversation.id == "created-id" + + @pytest.mark.asyncio + async def test_create_sets_identity_on_result(self, proactive, options, identity): + adapter = self._make_adapter("id-check") + result = await proactive.create_conversation(adapter, options) + assert result.claims.get("aud") == "app-id" + + @pytest.mark.asyncio + async def test_create_calls_adapter_create_conversation(self, proactive, options): + adapter = self._make_adapter("adapter-called") + await proactive.create_conversation(adapter, options) + adapter.create_conversation.assert_called_once() + + @pytest.mark.asyncio + async def test_create_validates_options(self, proactive): + adapter = MagicMock() + with pytest.raises(ValueError): + await proactive.create_conversation(adapter, CreateConversationOptions()) + + @pytest.mark.asyncio + async def test_create_stores_conversation_when_flag_set(self, proactive, identity): + opts = CreateConversationOptions( + identity=identity, + channel_id="msteams", + parameters=ConversationParameters(), + service_url="https://smba.trafficmanager.net/teams/", + store_conversation=True, + ) + adapter = self._make_adapter("auto-stored") + await proactive.create_conversation(adapter, opts) + stored = await proactive.get_conversation("auto-stored") + assert stored is not None + + @pytest.mark.asyncio + async def test_create_does_not_store_by_default(self, proactive, options): + adapter = self._make_adapter("not-stored") + await proactive.create_conversation(adapter, options) + stored = await proactive.get_conversation("not-stored") + assert stored is None + + @pytest.mark.asyncio + async def test_create_invokes_handler(self, proactive, options): + handler_called = False + + async def handler(ctx, state): + nonlocal handler_called + handler_called = True + + state = _make_mock_state() + adapter = self._make_adapter("handler-conv") + + with patch.object(proactive, "_load_state", AsyncMock(return_value=state)): + await proactive.create_conversation(adapter, options, handler=handler) + + assert handler_called + + @pytest.mark.asyncio + async def test_create_without_handler_does_not_raise(self, proactive, options): + adapter = self._make_adapter("no-handler") + result = await proactive.create_conversation(adapter, options, handler=None) + assert result is not None + + @pytest.mark.asyncio + async def test_create_passes_channel_id_to_adapter(self, proactive, options): + captured_channel_id = None + + async def fake_create(app_id, channel_id, service_url, audience, params, callback): + nonlocal captured_channel_id + captured_channel_id = channel_id + ref = _make_reference("x") + ctx = MagicMock() + ctx.activity.get_conversation_reference.return_value = ref + await callback(ctx) + + adapter = MagicMock() + adapter.create_conversation = AsyncMock(side_effect=fake_create) + + await proactive.create_conversation(adapter, options) + assert captured_channel_id == "msteams"