diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py index e16c1d34..fb396430 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/__init__.py @@ -1,6 +1,8 @@ # Import necessary modules from .activity_handler import ActivityHandler from .agent import Agent +from .basic_oauth_flow import BasicOAuthFlow +from .card_factory import CardFactory from .channel_adapter import ChannelAdapter from .channel_api_handler_protocol import ChannelApiHandlerProtocol from .channel_service_adapter import ChannelServiceAdapter @@ -14,6 +16,8 @@ __all__ = [ "ActivityHandler", "Agent", + "BasicOAuthFlow", + "CardFactory", "ChannelAdapter", "ChannelApiHandlerProtocol", "ChannelServiceAdapter", diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py new file mode 100644 index 00000000..e8f795ee --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/basic_oauth_flow.py @@ -0,0 +1,199 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import base64 +from datetime import datetime +import json + +from microsoft.agents.connector import UserTokenClient +from microsoft.agents.core.models import ( + ActionTypes, + CardAction, + Attachment, + OAuthCard, + SignInResource, + TokenExchangeState, +) +from microsoft.agents.core import ( + TurnContextProtocol as TurnContext, +) +from microsoft.agents.storage import StoreItem +from pydantic import BaseModel, ConfigDict + +from .message_factory import MessageFactory +from .card_factory import CardFactory +from .state.state_property_accessor import StatePropertyAccessor +from .state.user_state import UserState + + +class FlowState(StoreItem, BaseModel): + flow_started: bool = False + user_token: str = "" + flow_expires: float = 0 + + def store_item_to_json(self) -> dict: + return self.model_dump() + + @staticmethod + def from_json_to_store_item(json_data: dict) -> "StoreItem": + return FlowState.model_validate(json_data) + + +class BasicOAuthFlow: + """ + Manages the OAuth flow for Web Chat. + """ + + def __init__(self, user_state: UserState, connection_name: str, app_id: str): + """ + Creates a new instance of BasicOAuthFlow. + :param user_state: The user state. + """ + if not connection_name: + raise ValueError( + "BasicOAuthFlow.__init__: connectionName expected but not found" + ) + if not app_id: + raise ValueError( + "BasicOAuthFlow.__init__: appId expected but not found. Ensure the appId is set in your environment variables." + ) + + self.connection_name = connection_name + self.app_id = app_id + self.state: FlowState | None = None + self.flow_state_accessor: StatePropertyAccessor = user_state.create_property( + "flowState" + ) + + async def get_oauth_token(self, context: TurnContext) -> str: + """ + Gets the OAuth token. + :param context: The turn context. + :return: The user token. + """ + self.state = await self.get_user_state(context) + if self.state.user_token: + return self.state.user_token + + if ( + self.state.flow_expires + and self.state.flow_expires < datetime.now().timestamp() + ): + # logger.warn("Sign-in flow expired") + self.state.flow_started = False + self.state.user_token = "" + await context.send_activity( + MessageFactory.text("Sign-in session expired. Please try again.") + ) + + ret_val = "" + if not self.connection_name: + raise ValueError( + "connectionName is not set in the auth config, review your environment variables" + ) + + # TODO: Fix property discovery + token_client: UserTokenClient = context.turn_state.get( + context.adapter.USER_TOKEN_CLIENT_KEY + ) + + if self.state.flow_started: + user_token = await token_client.user_token.get_token( + user_id=context.activity.from_property.id, + connection_name=self.connection_name, + channel_id=context.activity.channel_id, + ) + if user_token: + # logger.info("Token obtained") + self.state.user_token = user_token["token"] + self.state.flow_started = False + else: + code = context.activity.text + user_token = await token_client.user_token.get_token( + user_id=context.activity.from_property.id, + connection_name=self.connection_name, + channel_id=context.activity.channel_id, + code=code, + ) + if user_token: + # logger.info("Token obtained with code") + self.state.user_token = user_token["token"] + self.state.flow_started = False + else: + # logger.error("Sign in failed") + await context.send_activity(MessageFactory.text("Sign in failed")) + ret_val = self.state.user_token + else: + token_exchange_state = TokenExchangeState( + connection_name=self.connection_name, + conversation=context.activity.get_conversation_reference(), + relates_to=context.activity.relates_to, + ms_app_id=self.app_id, + ) + serialized_state = base64.b64encode( + json.dumps(token_exchange_state.model_dump(by_alias=True)).encode( + encoding="UTF-8", errors="strict" + ) + ).decode() + token_client_response = ( + await token_client.agent_sign_in.get_sign_in_resource( + state=serialized_state, + ) + ) + signing_resource = SignInResource.model_validate(token_client_response) + # TODO: move this to CardFactory + o_card: Attachment = CardFactory.oauth_card( + OAuthCard( + text="Sign in", + connection_name=self.connection_name, + buttons=[ + CardAction( + title="Sign in", + text="", + type=ActionTypes.signin, + value=signing_resource.sign_in_link, + ) + ], + token_exchange_resource=signing_resource.token_exchange_resource, + ) + ) + await context.send_activity(MessageFactory.attachment(o_card)) + self.state.flow_started = True + self.state.flow_expires = datetime.now().timestamp() + 30000 + # logger.info("OAuth flow started") + + await self.flow_state_accessor.set(context, self.state) + return ret_val + + async def sign_out(self, context: TurnContext): + """ + Signs the user out. + :param context: The turn context. + """ + token_client: UserTokenClient = context.turn_state.get( + context.adapter.USER_TOKEN_CLIENT_KEY + ) + + await token_client.user_token.sign_out( + user_id=context.activity.from_property.id, + connection_name=self.connection_name, + channel_id=context.activity.channel_id, + ) + self.state.flow_started = False + self.state.user_token = "" + self.state.flow_expires = 0 + await self.flow_state_accessor.set(context, self.state) + # logger.info("User signed out successfully") + + async def get_user_state(self, context: TurnContext) -> FlowState: + """ + Gets the user state. + :param context: The turn context. + :return: The user state. + """ + user_profile: FlowState | None = await self.flow_state_accessor.get( + context, target_cls=FlowState + ) + if user_profile is None: + user_profile = FlowState() + return user_profile diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/card_factory.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/card_factory.py new file mode 100644 index 00000000..d8befe09 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/card_factory.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft.agents.core.models import ( + AnimationCard, + Attachment, + AudioCard, + HeroCard, + OAuthCard, + ReceiptCard, + SigninCard, + ThumbnailCard, + VideoCard, +) + + +class ContentTypes: + adaptive_card = "application/vnd.microsoft.card.adaptive" + animation_card = "application/vnd.microsoft.card.animation" + audio_card = "application/vnd.microsoft.card.audio" + hero_card = "application/vnd.microsoft.card.hero" + receipt_card = "application/vnd.microsoft.card.receipt" + oauth_card = "application/vnd.microsoft.card.oauth" + signin_card = "application/vnd.microsoft.card.signin" + thumbnail_card = "application/vnd.microsoft.card.thumbnail" + video_card = "application/vnd.microsoft.card.video" + + +class CardFactory: + content_types = ContentTypes + + @staticmethod + def adaptive_card(card: dict) -> Attachment: + """ + Returns an attachment for an adaptive card. The attachment will contain the card and the + appropriate 'contentType'. Will raise a TypeError if the 'card' argument is not an + dict. + :param card: + :return: + """ + if not isinstance(card, dict): + raise TypeError( + "CardFactory.adaptive_card(): `card` argument is not of type dict, unable to prepare " + "attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.adaptive_card, content=card + ) + + @staticmethod + def animation_card(card: AnimationCard) -> Attachment: + """ + Returns an attachment for an animation card. Will raise a TypeError if the 'card' argument is not an + AnimationCard. + :param card: + :return: + """ + if not isinstance(card, AnimationCard): + raise TypeError( + "CardFactory.animation_card(): `card` argument is not an instance of an AnimationCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.animation_card, content=card + ) + + @staticmethod + def audio_card(card: AudioCard) -> Attachment: + """ + Returns an attachment for an audio card. Will raise a TypeError if 'card' argument is not an AudioCard. + :param card: + :return: + """ + if not isinstance(card, AudioCard): + raise TypeError( + "CardFactory.audio_card(): `card` argument is not an instance of an AudioCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.audio_card, content=card + ) + + @staticmethod + def hero_card(card: HeroCard) -> Attachment: + """ + Returns an attachment for a hero card. Will raise a TypeError if 'card' argument is not a HeroCard. + + Hero cards tend to have one dominant full width image and the cards text & buttons can + usually be found below the image. + :return: + """ + if not isinstance(card, HeroCard): + raise TypeError( + "CardFactory.hero_card(): `card` argument is not an instance of an HeroCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.hero_card, content=card + ) + + @staticmethod + def oauth_card(card: OAuthCard) -> Attachment: + """ + Returns an attachment for an OAuth card used by the Bot Frameworks Single Sign On (SSO) service. Will raise a + TypeError if 'card' argument is not a OAuthCard. + :param card: + :return: + """ + if not isinstance(card, OAuthCard): + raise TypeError( + "CardFactory.oauth_card(): `card` argument is not an instance of an OAuthCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.oauth_card, content=card + ) + + @staticmethod + def receipt_card(card: ReceiptCard) -> Attachment: + """ + Returns an attachment for a receipt card. Will raise a TypeError if 'card' argument is not a ReceiptCard. + :param card: + :return: + """ + if not isinstance(card, ReceiptCard): + raise TypeError( + "CardFactory.receipt_card(): `card` argument is not an instance of an ReceiptCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.receipt_card, content=card + ) + + @staticmethod + def signin_card(card: SigninCard) -> Attachment: + """ + Returns an attachment for a signin card. For channels that don't natively support signin cards an alternative + message will be rendered. Will raise a TypeError if 'card' argument is not a SigninCard. + :param card: + :return: + """ + if not isinstance(card, SigninCard): + raise TypeError( + "CardFactory.signin_card(): `card` argument is not an instance of an SigninCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.signin_card, content=card + ) + + @staticmethod + def thumbnail_card(card: ThumbnailCard) -> Attachment: + """ + Returns an attachment for a thumbnail card. Thumbnail cards are similar to + but instead of a full width image, they're typically rendered with a smaller thumbnail version of + the image on either side and the text will be rendered in column next to the image. Any buttons + will typically show up under the card. Will raise a TypeError if 'card' argument is not a ThumbnailCard. + :param card: + :return: + """ + if not isinstance(card, ThumbnailCard): + raise TypeError( + "CardFactory.thumbnail_card(): `card` argument is not an instance of an ThumbnailCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.thumbnail_card, content=card + ) + + @staticmethod + def video_card(card: VideoCard) -> Attachment: + """ + Returns an attachment for a video card. Will raise a TypeError if 'card' argument is not a VideoCard. + :param card: + :return: + """ + if not isinstance(card, VideoCard): + raise TypeError( + "CardFactory.video_card(): `card` argument is not an instance of an VideoCard, " + "unable to prepare attachment." + ) + + return Attachment( + content_type=CardFactory.content_types.video_card, content=card + ) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py index 90c03755..0f4ed2df 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_adapter.py @@ -21,6 +21,10 @@ class ChannelAdapter(ABC, ChannelAdapterProtocol): AGENT_IDENTITY_KEY = "AgentIdentity" OAUTH_SCOPE_KEY = "Microsoft.Agents.Builder.ChannelAdapter.OAuthScope" INVOKE_RESPONSE_KEY = "ChannelAdapter.InvokeResponse" + CONNECTOR_FACTORY_KEY = "ConnectorFactory" + USER_TOKEN_CLIENT_KEY = "UserTokenClient" + AGENT_CALLBACK_HANDLER_KEY = "AgentCallbackHandler" + CHANNEL_SERVICE_FACTORY_KEY = "ChannelServiceClientFactory" on_turn_error: Callable[[TurnContext, Exception], Awaitable] = None diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py index 501e9e9f..975a40b5 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/channel_service_adapter.py @@ -38,10 +38,6 @@ class ChannelServiceAdapter(ChannelAdapter, ABC): - CONNECTOR_FACTORY_KEY = "ConnectorFactory" - USER_TOKEN_CLIENT_KEY = "UserTokenClient" - AGENT_CALLBACK_HANDLER_KEY = "AgentCallbackHandler" - CHANNEL_SERVICE_FACTORY_KEY = "ChannelServiceClientFactory" _AGENT_CONNECTOR_CLIENT_KEY = "ConnectorClient" _INVOKE_RESPONSE_KEY = "ChannelServiceAdapter.InvokeResponse" diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py index 66d642b0..d5c8497c 100644 --- a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/rest_channel_service_client_factory.py @@ -56,8 +56,10 @@ async def create_user_token_client( self, claims_identity: ClaimsIdentity, use_anonymous: bool = False ) -> UserTokenClient: return UserTokenClient( - credential=self._connections.get_token_provider( + credential_token_provider=self._connections.get_token_provider( claims_identity, self._token_service_endpoint ), + credential_resource_url=self._token_service_audience, + credential_scopes=[f"{self._token_service_audience}/.default"], endpoint=self._token_service_endpoint, ) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py new file mode 100644 index 00000000..ba4a4f00 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/__init__.py @@ -0,0 +1,5 @@ +from .agent_state import AgentState +from .state_property_accessor import StatePropertyAccessor +from .user_state import UserState + +__all__ = ["AgentState", "StatePropertyAccessor", "UserState"] diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py new file mode 100644 index 00000000..096ae619 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/agent_state.py @@ -0,0 +1,373 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from __future__ import annotations + +from abc import abstractmethod +from copy import deepcopy +from typing import Callable, Dict, Union, Type + +from microsoft.agents.storage import Storage, StoreItem + +from .state_property_accessor import StatePropertyAccessor +from ..turn_context import TurnContext + + +class CachedAgentState(StoreItem): + """ + Internal cached bot state. + """ + + def __init__(self, state: Dict[str, StoreItem | dict] = None): + if state: + self.state = state + internal_hash = state.pop("CachedAgentState._hash", None) + self.hash = internal_hash or self.compute_hash() + else: + self.state = {} + self.hash = hash(str({})) + + @property + def has_state(self) -> bool: + return bool(self.state) + + @property + def is_changed(self) -> bool: + return self.hash != self.compute_hash() + + def compute_hash(self) -> str: + return hash(str(self.store_item_to_json())) + + def store_item_to_json(self) -> dict: + if not self.state: + return {} + # TODO: Might need to change this check to include Types that implement but not inherit. + serialized = { + key: value.store_item_to_json() if isinstance(value, StoreItem) else value + for key, value in self.state.items() + } + serialized["CachedAgentState._hash"] = self.hash + return serialized + + @staticmethod + def from_json_to_store_item(json_data: dict) -> StoreItem: + return CachedAgentState(json_data) + + +class AgentState: + """ + Defines a state management object and automates the reading and writing of + associated state properties to a storage layer. + + .. remarks:: + Each state management object defines a scope for a storage layer. + State properties are created within a state management scope, and the Bot Framework + defines these scopes: :class:`ConversationState`, :class:`UserState`, and :class:`PrivateConversationState`. + You can define additional scopes for your bot. + """ + + def __init__(self, storage: Storage, context_service_key: str): + """ + Initializes a new instance of the :class:`BotState` class. + + :param storage: The storage layer this state management object will use to store and retrieve state + :type storage: :class:`bptbuilder.core.Storage` + :param context_service_key: The key for the state cache for this :class:`BotState` + :type context_service_key: str + + .. remarks:: + This constructor creates a state management object and associated scope. The object uses + the :param storage: to persist state property values and the :param context_service_key: to cache state + within the context for each turn. + + :raises: It raises an argument null exception. + """ + self.state_key = "state" + self._storage = storage + self._context_service_key = context_service_key + + def get_cached_state(self, turn_context: TurnContext) -> CachedAgentState: + """ + Gets the cached bot state instance that wraps the raw cached data for this "BotState" + from the turn context. + + :param turn_context: The context object for this turn. + :type turn_context: :class:`TurnContext` + :return: The cached bot state instance. + """ + _assert_value(turn_context, self.get_cached_state.__name__) + return turn_context.turn_state.get(self._context_service_key) + + def create_property(self, name: str) -> StatePropertyAccessor: + """ + Creates a property definition and registers it with this :class:`BotState`. + + :param name: The name of the property + :type name: str + :return: If successful, the state property accessor created + :rtype: :class:`StatePropertyAccessor` + """ + if not name: + raise TypeError("BotState.create_property(): name cannot be None or empty.") + return BotStatePropertyAccessor(self, name) + + def get(self, turn_context: TurnContext) -> Dict[str, StoreItem]: + _assert_value(turn_context, self.get.__name__) + cached = self.get_cached_state(turn_context) + + return getattr(cached, "state", None) + + async def load(self, turn_context: TurnContext, force: bool = False) -> None: + """ + Reads the current state object and caches it in the context object for this turn. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param force: Optional, true to bypass the cache + :type force: bool + """ + _assert_value(turn_context, self.load.__name__) + + cached_state = self.get_cached_state(turn_context) + storage_key = self.get_storage_key(turn_context) + + if force or not cached_state: + items = await self._storage.read([storage_key], target_cls=CachedAgentState) + val = items.get(storage_key, CachedAgentState()) + turn_context.turn_state[self._context_service_key] = val + + async def save_changes( + self, turn_context: TurnContext, force: bool = False + ) -> None: + """ + Saves the state cached in the current context for this turn. + If the state has changed, it saves the state cached in the current context for this turn. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param force: Optional, true to save state to storage whether or not there are changes + :type force: bool + """ + _assert_value(turn_context, self.save_changes.__name__) + + cached_state = self.get_cached_state(turn_context) + + if force or (cached_state is not None and cached_state.is_changed): + storage_key = self.get_storage_key(turn_context) + changes: Dict[str, StoreItem] = {storage_key: cached_state} + await self._storage.write(changes) + cached_state.hash = cached_state.compute_hash() + + async def clear_state(self, turn_context: TurnContext): + """ + Clears any state currently stored in this state scope. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :return: None + + .. remarks:: + This function must be called in order for the cleared state to be persisted to the underlying store. + """ + _assert_value(turn_context, self.clear_state.__name__) + + # Explicitly setting the hash will mean IsChanged is always true. And that will force a Save. + cache_value = CachedAgentState() + cache_value.hash = "" + turn_context.turn_state[self._context_service_key] = cache_value + + async def delete(self, turn_context: TurnContext) -> None: + """ + Deletes any state currently stored in this state scope. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :return: None + """ + _assert_value(turn_context, self.delete.__name__) + + turn_context.turn_state.pop(self._context_service_key) + + storage_key = self.get_storage_key(turn_context) + await self._storage.delete({storage_key}) + + @abstractmethod + def get_storage_key( + self, turn_context: TurnContext, *, target_cls: Type[StoreItem] = None + ) -> str: + raise NotImplementedError() + + async def get_property_value( + self, + turn_context: TurnContext, + property_name: str, + *, + target_cls: Type[StoreItem] = None, + ) -> StoreItem: + """ + Gets the value of the specified property in the turn context. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param property_name: The property name + :type property_name: str + + :return: The value of the property + """ + _assert_value(turn_context, self.get_property_value.__name__) + + if not property_name: + raise TypeError( + "BotState.get_property_value(): property_name cannot be None." + ) + cached_state = self.get_cached_state(turn_context) + + # if there is no value, this will throw, to signal to IPropertyAccesor that a default value should be computed + # This allows this to work with value types + value = cached_state.state[property_name] + + if target_cls: + # Attempt to deserialize the value if it is not None + try: + return target_cls.from_json_to_store_item(value) + except AttributeError: + # If the value is not a StoreItem, just return it as is + pass + + return value + + async def delete_property_value( + self, turn_context: TurnContext, property_name: str + ) -> None: + """ + Deletes a property from the state cache in the turn context. + + :param turn_context: The context object for this turn + :type turn_context: :TurnContext` + :param property_name: The name of the property to delete + :type property_name: str + + :return: None + """ + _assert_value(turn_context, self.delete_property_value.__name__) + if not property_name: + raise TypeError("BotState.delete_property(): property_name cannot be None.") + cached_state = self.get_cached_state(turn_context) + del cached_state.state[property_name] + + async def set_property_value( + self, turn_context: TurnContext, property_name: str, value: StoreItem + ) -> None: + """ + Sets a property to the specified value in the turn context. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param property_name: The property name + :type property_name: str + :param value: The value to assign to the property + :type value: StoreItem + + :return: None + """ + _assert_value(turn_context, self.set_property_value.__name__) + + if not property_name: + raise TypeError("BotState.delete_property(): property_name cannot be None.") + cached_state = self.get_cached_state(turn_context) + cached_state.state[property_name] = value + + +class BotStatePropertyAccessor(StatePropertyAccessor): + """ + Defines methods for accessing a state property created in a :class:`BotState` object. + """ + + def __init__(self, bot_state: AgentState, name: str): + """ + Initializes a new instance of the :class:`BotStatePropertyAccessor` class. + + :param bot_state: The state object to access + :type bot_state: :class:`BotState` + :param name: The name of the state property to access + :type name: str + + """ + self._bot_state = bot_state + self._name = name + + @property + def name(self) -> str: + """ + The name of the property. + """ + return self._name + + async def delete(self, turn_context: TurnContext) -> None: + """ + Deletes the property. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + """ + await self._bot_state.load(turn_context, False) + await self._bot_state.delete_property_value(turn_context, self._name) + + async def get( + self, + turn_context: TurnContext, + default_value_or_factory: Union[Callable, StoreItem] = None, + *, + target_cls: Type[StoreItem] = None, + ) -> StoreItem: + """ + Gets the property value. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + :param default_value_or_factory: Defines the default value for the property + """ + await self._bot_state.load(turn_context, False) + try: + result = await self._bot_state.get_property_value( + turn_context, self._name, target_cls=target_cls + ) + return result + except: + # ask for default value from factory + if not default_value_or_factory: + return None + result = ( + default_value_or_factory() + if callable(default_value_or_factory) + else deepcopy(default_value_or_factory) + ) + # save default value for any further calls + await self.set(turn_context, result) + return result + + async def set(self, turn_context: TurnContext, value: StoreItem) -> None: + """ + Sets the property value. + + :param turn_context: The context object for this turn + :type turn_context: :class:`TurnContext` + + :param value: The value to assign to the property + """ + await self._bot_state.load(turn_context, False) + await self._bot_state.set_property_value(turn_context, self._name, value) + + +def _assert_value(value: StoreItem, func_name: str): + """ + Asserts that the value is present. + + :param value: The value to check + """ + if value is None: + raise TypeError( + f"BotStatePropertyAccessor.{func_name}: expecting {value.__class__.__name__} but got None instead." + ) diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py new file mode 100644 index 00000000..ed5c5170 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/state_property_accessor.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import abstractmethod +from collections.abc import Callable +from typing import Protocol, Type, Union + +from microsoft.agents.storage import StoreItem + +from ..turn_context import TurnContext + + +class StatePropertyAccessor(Protocol): + @abstractmethod + async def get( + self, + turn_context: TurnContext, + default_value_or_factory: Union[Callable, StoreItem] = None, + *, + target_cls: Type[StoreItem] = None + ) -> object: + """ + Get the property value from the source + :param turn_context: Turn Context. + :param default_value_or_factory: Function which defines the property + value to be returned if no value has been set. + + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def delete(self, turn_context: TurnContext) -> None: + """ + Saves store items to storage. + :param turn_context: Turn Context. + :return: + """ + raise NotImplementedError() + + @abstractmethod + async def set(self, turn_context: TurnContext, value) -> None: + """ + Set the property value on the source. + :param turn_context: Turn Context. + :param value: + :return: + """ + raise NotImplementedError() diff --git a/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/user_state.py b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/user_state.py new file mode 100644 index 00000000..c3744141 --- /dev/null +++ b/libraries/Builder/microsoft-agents-builder/microsoft/agents/builder/state/user_state.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from microsoft.agents.storage import Storage + +from ..turn_context import TurnContext +from .agent_state import AgentState + + +class UserState(AgentState): + """ + Reads and writes user state for your bot to storage. + """ + + no_key_error_message = ( + "UserState: channel_id and/or conversation missing from context.activity." + ) + + def __init__(self, storage: Storage, namespace=""): + """ + Creates a new UserState instance. + :param storage: + :param namespace: + """ + self.namespace = namespace + + super(UserState, self).__init__(storage, "Internal.UserState") + + def get_storage_key(self, turn_context: TurnContext) -> str: + """ + Returns the storage key for the current user state. + :param turn_context: + :return: + """ + channel_id = turn_context.activity.channel_id or self.__raise_type_error( + "Invalid Activity: missing channelId" + ) + user_id = turn_context.activity.from_property.id or self.__raise_type_error( + "Invalid Activity: missing from_property.id" + ) + + storage_key = None + if channel_id and user_id: + storage_key = f"{channel_id}/users/{user_id}" + if self.namespace: + storage_key += f"/{storage_key}" + + return storage_key + + def __raise_type_error(self, err: str = "NoneType found while expecting value"): + raise TypeError(err) diff --git a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py index e8194e37..f3aaada7 100644 --- a/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py +++ b/libraries/Client/microsoft-agents-connector/microsoft/agents/connector/token/user_token_client.py @@ -12,6 +12,7 @@ from azure.core.credentials_async import AsyncTokenCredential from azure.core.pipeline import policies from azure.core.rest import AsyncHttpResponse, HttpRequest +from microsoft.agents.authorization import AccessTokenProviderBase from ._user_token_client_configuration import TokenConfiguration from .operations import ( @@ -21,6 +22,7 @@ ) from .._serialization import Deserializer, Serializer from ..agent_sign_in_base import AgentSignInBase +from .._agents_token_credential_adapter import AgentsTokenCredentialAdapter from ..user_token_base import UserTokenBase from ..user_token_client_base import UserTokenClientBase @@ -44,9 +46,23 @@ class UserTokenClient( """ def __init__( - self, credential: AsyncTokenCredential, endpoint: str = "", **kwargs: Any + self, + credential_token_provider: AccessTokenProviderBase, + credential_resource_url: str, + credential_scopes: list[str] = None, + endpoint: str = "", + **kwargs: Any ) -> None: - self._config = TokenConfiguration(credential=credential, **kwargs) + + agents_token_credential = AgentsTokenCredentialAdapter( + credential_token_provider, credential_resource_url + ) + self._config = TokenConfiguration( + credential=agents_token_credential, + credential_scopes=credential_scopes, + **kwargs + ) + _policies = kwargs.pop("policies", None) if _policies is None: _policies = [ diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py index 71eda3fa..1e7c2f10 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/__init__.py @@ -1,4 +1,5 @@ from .agents_model import AgentsModel +from .action_types import ActionTypes from .activity import Activity from .activity_event_names import ActivityEventNames from .activity_types import ActivityTypes @@ -57,7 +58,6 @@ from .transcript import Transcript from .video_card import VideoCard -from .activity_types import ActivityTypes from .activity_importance import ActivityImportance from .attachment_layout_types import AttachmentLayoutTypes from .contact_relation_update_action_types import ContactRelationUpdateActionTypes @@ -81,6 +81,7 @@ __all__ = [ "AgentsModel", "Activity", + "ActionTypes", "ActivityEventNames", "AdaptiveCardInvokeAction", "AdaptiveCardInvokeResponse", diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py index 0c7c7903..db3b045c 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/card_action.py @@ -30,9 +30,9 @@ class CardAction(AgentsModel): type: NonEmptyString title: NonEmptyString - image: NonEmptyString = None - text: NonEmptyString = None - display_text: NonEmptyString = None + image: str = None + text: str = None + display_text: str = None value: object = None channel_data: object = None - image_alt_text: NonEmptyString = None + image_alt_text: str = None diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py index 56aa2c46..548d99ce 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/models/token_exchange_state.py @@ -1,3 +1,4 @@ +from typing import Optional from pydantic import Field from .conversation_reference import ConversationReference @@ -22,6 +23,6 @@ class TokenExchangeState(AgentsModel): connection_name: NonEmptyString = None conversation: ConversationReference = None - relates_to: ConversationReference = None + relates_to: Optional[ConversationReference] = None agent_url: NonEmptyString = Field(None, alias="bot_url") ms_app_id: NonEmptyString = None diff --git a/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py b/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py index d614e91d..965a5ccb 100644 --- a/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py +++ b/libraries/Core/microsoft-agents-core/microsoft/agents/core/turn_context_protocol.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Protocol, List, Callable, Awaitable, Optional, Generic, TypeVar +from typing import Protocol, List, Callable, Optional, Generic, TypeVar from abc import abstractmethod from microsoft.agents.core.models import ( diff --git a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py index a3876e28..8b0845f2 100644 --- a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py +++ b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/memory_storage.py @@ -21,14 +21,17 @@ async def read( with self._lock: for key in keys: if key in self._memory: - try: - result[key] = target_cls.from_json_to_store_item( - self._memory[key] - ) - except TypeError as error: - raise TypeError( - f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" - ) + if not target_cls: + result[key] = self._memory[key] + else: + try: + result[key] = target_cls.from_json_to_store_item( + self._memory[key] + ) + except AttributeError as error: + raise TypeError( + f"MemoryStorage.read(): could not deserialize in-memory item into {target_cls} class. Error: {error}" + ) return result async def write(self, changes: dict[str, StoreItem]): diff --git a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py index e2bda475..a2f7b13d 100644 --- a/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py +++ b/libraries/Storage/microsoft-agents-storage/microsoft/agents/storage/store_item.py @@ -1,10 +1,10 @@ +from abc import ABC from typing import Protocol, runtime_checkable from ._type_aliases import JSON -@runtime_checkable -class StoreItem(Protocol): +class StoreItem(ABC): def store_item_to_json(self) -> JSON: pass diff --git a/test_samples/wc_sso_agent/app.py b/test_samples/wc_sso_agent/app.py new file mode 100644 index 00000000..c2a2febb --- /dev/null +++ b/test_samples/wc_sso_agent/app.py @@ -0,0 +1,70 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from dotenv import load_dotenv +from aiohttp.web import Application, Request, Response, run_app + +from microsoft.agents.builder import RestChannelServiceClientFactory +from microsoft.agents.builder.state import UserState +from microsoft.agents.hosting.aiohttp import CloudAdapter, jwt_authorization_middleware +from microsoft.agents.authorization import ( + Connections, + AccessTokenProviderBase, + ClaimsIdentity, +) +from microsoft.agents.authentication.msal import MsalAuth +from microsoft.agents.storage import MemoryStorage + +from sso_agent import SsoAgent +from config import DefaultConfig + +load_dotenv() + +CONFIG = DefaultConfig() +AUTH_PROVIDER = MsalAuth(DefaultConfig()) + + +class DefaultConnection(Connections): + def get_default_connection(self) -> AccessTokenProviderBase: + pass + + def get_token_provider( + self, claims_identity: ClaimsIdentity, service_url: str + ) -> AccessTokenProviderBase: + return AUTH_PROVIDER + + def get_connection(self, connection_name: str) -> AccessTokenProviderBase: + pass + + +CHANNEL_CLIENT_FACTORY = RestChannelServiceClientFactory(CONFIG, DefaultConnection()) + +# Create adapter. +ADAPTER = CloudAdapter(CHANNEL_CLIENT_FACTORY) + +# Create the storage. +STORAGE = MemoryStorage() + +# Create the user state and conversation state. +USER_STATE = UserState(STORAGE) + +# Create the Agent +AGENT = SsoAgent(USER_STATE, CONFIG.CONNECTION_NAME, CONFIG.CLIENT_ID) + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + adapter: CloudAdapter = req.app["adapter"] + return await adapter.process(req, AGENT) + + +APP = Application(middlewares=[jwt_authorization_middleware]) +APP.router.add_post("/api/messages", messages) +APP["agent_configuration"] = CONFIG +APP["adapter"] = ADAPTER + +if __name__ == "__main__": + try: + run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/test_samples/wc_sso_agent/config.py b/test_samples/wc_sso_agent/config.py new file mode 100644 index 00000000..7b6735bb --- /dev/null +++ b/test_samples/wc_sso_agent/config.py @@ -0,0 +1,14 @@ +from os import environ +from microsoft.agents.authentication.msal import AuthTypes, MsalAuthConfiguration + + +class DefaultConfig(MsalAuthConfiguration): + """Agent Configuration""" + + def __init__(self) -> None: + self.AUTH_TYPE = AuthTypes.client_secret + self.TENANT_ID = "" or environ.get("TENANT_ID") + self.CLIENT_ID = "" or environ.get("CLIENT_ID") + self.CLIENT_SECRET = "" or environ.get("CLIENT_SECRET") + self.CONNECTION_NAME = "" or environ.get("CONNECTION_NAME") + self.PORT = 3978 diff --git a/test_samples/wc_sso_agent/graph_client.py b/test_samples/wc_sso_agent/graph_client.py new file mode 100644 index 00000000..a9b5bd7a --- /dev/null +++ b/test_samples/wc_sso_agent/graph_client.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +from urllib.parse import urlparse, urljoin + +from requests_oauthlib import OAuth2Session + +AUTHORITY_URL = "https://login.microsoftonline.com/common" +RESOURCE = "https://graph.microsoft.com" +API_VERSION = "v1.0" + + +class GraphClient: + def __init__(self, token: str): + self.token = token + self.client = OAuth2Session( + token={"access_token": token, "token_type": "Bearer"} + ) + + async def get_me(self) -> dict: + response = self.client.get(self.api_endpoint("me")) + return json.loads(response.text) + + def api_endpoint(self, url): + """Convert a relative path such as /me/photo/$value to a full URI based + on the current RESOURCE and API_VERSION settings in config.py. + """ + if urlparse(url).scheme in ["http", "https"]: + return url # url is already complete + return urljoin(f"{RESOURCE}/{API_VERSION}/", url.lstrip("/")) diff --git a/test_samples/wc_sso_agent/sso_agent.py b/test_samples/wc_sso_agent/sso_agent.py new file mode 100644 index 00000000..79a459a2 --- /dev/null +++ b/test_samples/wc_sso_agent/sso_agent.py @@ -0,0 +1,72 @@ +from microsoft.agents.core.models import ChannelAccount +from microsoft.agents.builder import ( + ActivityHandler, + BasicOAuthFlow, + MessageFactory, + TurnContext, +) +from microsoft.agents.builder.state import UserState + +from graph_client import GraphClient + + +class SsoAgent(ActivityHandler): + + def __init__(self, user_state: UserState, connection_name: str, app_id: str): + """ + Initializes a new instance of the SsoAgent class. + :param user_state: The user state. + """ + self.user_state = user_state + self.oauth_flow = BasicOAuthFlow(user_state, connection_name, app_id) + + async def on_members_added_activity( + self, members_added: list[ChannelAccount], turn_context: TurnContext + ): + for member in members_added: + if member.id != turn_context.activity.recipient.id: + await turn_context.send_activity("Hello and welcome!") + + async def on_message_activity(self, turn_context: TurnContext): + if turn_context.activity.text == "login": + await self.oauth_flow.get_oauth_token(turn_context) + elif turn_context.activity.text == "logout": + await self.oauth_flow.sign_out(turn_context) + await turn_context.send_activity( + MessageFactory.text("You have been signed out.") + ) + else: + if len(turn_context.activity.text.strip()) != 6: + await turn_context.send_activity( + MessageFactory.text( + 'Please enter "login" to sign in or "logout" to sign out' + ) + ) + else: + await self.get_token(turn_context) + + async def on_turn(self, turn_context): + await super().on_turn(turn_context) + await self.user_state.save_changes(turn_context) + + async def get_token(self, turn_context: TurnContext): + """ + Gets the OAuth token. + :param turn_context: The turn context. + :return: The user token. + """ + user_token = await self.oauth_flow.get_oauth_token(turn_context) + if user_token: + await self.send_logged_user_info(turn_context, user_token) + + async def send_logged_user_info(self, turn_context: TurnContext, token: str): + """ + Sends the logged user info. + :param turn_context: The turn context. + """ + graph_client = GraphClient(token) + user_info = await graph_client.get_me() + message = ( + f"You are {user_info['displayName']} and your email is {user_info['mail']}." + ) + await turn_context.send_activity(MessageFactory.text(message))