diff --git a/.gitignore b/.gitignore index 0723fd02..900f432a 100644 --- a/.gitignore +++ b/.gitignore @@ -125,3 +125,6 @@ cython_debug/ # vscode .vscode/ + +# Binary files +bin/ \ No newline at end of file diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py new file mode 100644 index 00000000..6b5e2107 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/__init__.py @@ -0,0 +1,19 @@ +from .bot_type import BotType +from .connection_settings import ConnectionSettings +from .copilot_client import CopilotClient +from .direct_to_engine_connection_settings_protocol import ( + DirectToEngineConnectionSettingsProtocol, +) +from .execute_turn_request import ExecuteTurnRequest +from .power_platform_cloud import PowerPlatformCloud +from .power_platform_environment import PowerPlatformEnvironment + +__all__ = [ + "BotType", + "ConnectionSettings", + "CopilotClient", + "DirectToEngineConnectionSettingsProtocol", + "ExecuteTurnRequest", + "PowerPlatformCloud", + "PowerPlatformEnvironment", +] diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py new file mode 100644 index 00000000..cd6dc138 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/bot_type.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class BotType(str, Enum): + PUBLISHED = "published" + PREBUILT = "prebuilt" diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py new file mode 100644 index 00000000..82bc570a --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/connection_settings.py @@ -0,0 +1,32 @@ +from typing import Optional +from .direct_to_engine_connection_settings_protocol import ( + DirectToEngineConnectionSettingsProtocol, +) +from .power_platform_cloud import PowerPlatformCloud +from .bot_type import BotType + + +class ConnectionSettings(DirectToEngineConnectionSettingsProtocol): + """ + Connection settings for the DirectToEngineConnectionConfiguration. + """ + + def __init__( + self, + environment_id: str, + bot_identifier: str, + cloud: Optional[PowerPlatformCloud], + copilot_bot_type: Optional[BotType], + custom_power_platform_cloud: Optional[str], + ) -> None: + self.environment_id = environment_id + self.bot_identifier = bot_identifier + + if not self.environment_id: + raise ValueError("Environment ID must be provided") + if not self.bot_identifier: + raise ValueError("Bot Identifier must be provided") + + self.cloud = cloud or PowerPlatformCloud.UNKNOWN + self.copilot_bot_type = copilot_bot_type or BotType.PUBLISHED + self.custom_power_platform_cloud = custom_power_platform_cloud diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py new file mode 100644 index 00000000..392af90d --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/copilot_client.py @@ -0,0 +1,106 @@ +import aiohttp +from typing import AsyncIterable, Callable, Optional + +from microsoft.agents.core.models import Activity, ActivityTypes, ConversationAccount + +from .connection_settings import ConnectionSettings +from .execute_turn_request import ExecuteTurnRequest +from .power_platform_environment import PowerPlatformEnvironment + + +class CopilotClient: + EVENT_STREAM_TYPE = "text/event-stream" + APPLICATION_JSON_TYPE = "application/json" + + _current_conversation_id = "" + + def __init__( + self, + settings: ConnectionSettings, + token: str, + ): + self.settings = settings + self._token = token + # TODO: Add logger + # self.logger = logger + self.conversation_id = "" + + async def post_request( + self, url: str, data: dict, headers: dict + ) -> AsyncIterable[Activity]: + async with aiohttp.ClientSession() as session: + async with session.post(url, json=data, headers=headers) as response: + if response.status != 200: + # self.logger(f"Error sending request: {response.status}") + raise aiohttp.ClientError( + f"Error sending request: {response.status}" + ) + event_type = None + async for line in response.content: + if line.startswith(b"event:"): + event_type = line[6:].decode("utf-8").strip() + if line.startswith(b"data:") and event_type == "activity": + activity_data = line[5:].decode("utf-8").strip() + activity = Activity.model_validate_json(activity_data) + + if activity.type == ActivityTypes.message: + self._current_conversation_id = activity.conversation.id + + yield activity + + async def start_conversation( + self, emit_start_conversation_event: bool = True + ) -> AsyncIterable[Activity]: + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=self.settings + ) + data = {"emitStartConversationEvent": emit_start_conversation_event} + headers = { + "Content-Type": self.APPLICATION_JSON_TYPE, + "Authorization": f"Bearer {self._token}", + "Accept": self.EVENT_STREAM_TYPE, + } + + async for activity in self.post_request(url, data, headers): + yield activity + + async def ask_question( + self, question: str, conversation_id: Optional[str] = None + ) -> AsyncIterable[Activity]: + activity = Activity( + type="message", + text=question, + conversation=ConversationAccount( + id=conversation_id or self._current_conversation_id + ), + ) + + async for activity in self.ask_question_with_activity(activity): + yield activity + + async def ask_question_with_activity( + self, activity: Activity + ) -> AsyncIterable[Activity]: + if not activity: + raise ValueError( + "CopilotClient.ask_question_with_activity: Activity cannot be None" + ) + + local_conversation_id = ( + activity.conversation.id or self._current_conversation_id + ) + + url = PowerPlatformEnvironment.get_copilot_studio_connection_url( + settings=self.settings, conversation_id=local_conversation_id + ) + data = ExecuteTurnRequest(activity=activity).model_dump( + mode="json", by_alias=True, exclude_unset=True + ) + headers = { + "Content-Type": self.APPLICATION_JSON_TYPE, + "Authorization": f"Bearer {self._token}", + "Accept": self.EVENT_STREAM_TYPE, + } + + async for activity in self.post_request(url, data, headers): + yield activity diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py new file mode 100644 index 00000000..38eecffd --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/direct_to_engine_connection_settings_protocol.py @@ -0,0 +1,25 @@ +from typing import Protocol, Optional + +from .bot_type import BotType +from .power_platform_cloud import PowerPlatformCloud + + +class DirectToEngineConnectionSettingsProtocol(Protocol): + """ + Protocol for DirectToEngineConnectionConfiguration. + """ + + # Schema name for the Copilot Studio Hosted Copilot. + bot_identifier: Optional[str] + + # if PowerPlatformCloud is set to Other, this is the url for the power platform API endpoint. + custom_power_platform_cloud: Optional[str] + + # Environment ID for the environment that hosts the bot + environment_id: Optional[str] + + # Power Platform Cloud where the environment is hosted + cloud: Optional[PowerPlatformCloud] + + # Type of Bot hosted in Copilot Studio + copilot_bot_type: Optional[BotType] diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/execute_turn_request.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/execute_turn_request.py new file mode 100644 index 00000000..981e3c06 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/execute_turn_request.py @@ -0,0 +1,6 @@ +from microsoft.agents.core.models import AgentsModel, Activity + + +class ExecuteTurnRequest(AgentsModel): + + activity: Activity diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py new file mode 100644 index 00000000..87d75e9a --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_cloud.py @@ -0,0 +1,25 @@ +from enum import Enum + + +class PowerPlatformCloud(str, Enum): + """ + Enum representing different Power Platform Clouds. + """ + + UNKNOWN = "Unknown" + EXP = "Exp" + DEV = "Dev" + TEST = "Test" + PREPROD = "Preprod" + FIRST_RELEASE = "FirstRelease" + PROD = "Prod" + GOV = "Gov" + HIGH = "High" + DOD = "DoD" + MOONCAKE = "Mooncake" + EX = "Ex" + RX = "Rx" + PRV = "Prv" + LOCAL = "Local" + GOV_FR = "GovFR" + OTHER = "Other" diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_environment.py b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_environment.py new file mode 100644 index 00000000..a0228032 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/microsoft/agents/copilotstudio/client/power_platform_environment.py @@ -0,0 +1,156 @@ +from urllib.parse import urlparse, urlunparse +from typing import Optional +from .connection_settings import ConnectionSettings +from .bot_type import BotType +from .power_platform_cloud import PowerPlatformCloud + + +# TODO: POC provides the +class PowerPlatformEnvironment: + """ + Class representing the Power Platform Environment. + """ + + API_VERSION = "2022-03-01-preview" + + @staticmethod + def get_copilot_studio_connection_url( + settings: ConnectionSettings, + conversation_id: Optional[str] = None, + bot_type: BotType = BotType.PUBLISHED, + cloud: PowerPlatformCloud = PowerPlatformCloud.PROD, + cloud_base_address: Optional[str] = None, + ) -> str: + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError( + "cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + if not settings.environment_id: + raise ValueError("EnvironmentId must be provided") + if not settings.bot_identifier: + raise ValueError("BotIdentifier must be provided") + if settings.cloud and settings.cloud != PowerPlatformCloud.UNKNOWN: + cloud = settings.cloud + if cloud == PowerPlatformCloud.OTHER: + parsed_url = urlparse(cloud_base_address) + is_absolute_url = parsed_url.scheme and parsed_url.netloc + if cloud_base_address and is_absolute_url: + pass + elif settings.custom_power_platform_cloud: + cloud_base_address = settings.custom_power_platform_cloud + else: + raise ValueError( + "Either CustomPowerPlatformCloud or cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + if settings.copilot_bot_type: + bot_type = settings.copilot_bot_type + + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + host = PowerPlatformEnvironment.get_environment_endpoint( + cloud, settings.environment_id, cloud_base_address + ) + return PowerPlatformEnvironment.create_uri( + settings.bot_identifier, host, bot_type, conversation_id + ) + + @staticmethod + def get_token_audience( + settings: Optional[ConnectionSettings] = None, + cloud: PowerPlatformCloud = PowerPlatformCloud.UNKNOWN, + cloud_base_address: Optional[str] = None, + ) -> str: + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError( + "cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + if not settings and cloud == PowerPlatformCloud.UNKNOWN: + raise ValueError("Either settings or cloud must be provided") + if settings and settings.cloud and settings.cloud != PowerPlatformCloud.UNKNOWN: + cloud = settings.cloud + if cloud == PowerPlatformCloud.OTHER: + if cloud_base_address and urlparse(cloud_base_address).scheme: + cloud = PowerPlatformCloud.OTHER + elif ( + settings + and settings.custom_power_platform_cloud + and urlparse(settings.custom_power_platform_cloud).scheme + ): + cloud = PowerPlatformCloud.OTHER + cloud_base_address = settings.custom_power_platform_cloud + else: + raise ValueError( + "Either CustomPowerPlatformCloud or cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + return f"https://{PowerPlatformEnvironment.get_endpoint_suffix(cloud, cloud_base_address)}/.default" + + @staticmethod + def create_uri( + bot_identifier: str, + host: str, + bot_type: BotType, + conversation_id: Optional[str], + ) -> str: + bot_path_name = ( + "dataverse-backed" if bot_type == BotType.PUBLISHED else "prebuilt" + ) + path = f"/copilotstudio/{bot_path_name}/authenticated/bots/{bot_identifier}/conversations" + if conversation_id: + path += f"/{conversation_id}" + return urlunparse( + ( + "https", + host, + path, + "", + f"api-version={PowerPlatformEnvironment.API_VERSION}", + "", + ) + ) + + @staticmethod + def get_environment_endpoint( + cloud: PowerPlatformCloud, + environment_id: str, + cloud_base_address: Optional[str] = None, + ) -> str: + if cloud == PowerPlatformCloud.OTHER and not cloud_base_address: + raise ValueError( + "cloud_base_address must be provided when PowerPlatformCloud is Other" + ) + cloud_base_address = cloud_base_address or "api.unknown.powerplatform.com" + normalized_resource_id = environment_id.lower().replace("-", "") + id_suffix_length = PowerPlatformEnvironment.get_id_suffix_length(cloud) + hex_prefix = normalized_resource_id[:-id_suffix_length] + hex_suffix = normalized_resource_id[-id_suffix_length:] + return f"{hex_prefix}.{hex_suffix}.environment.{PowerPlatformEnvironment.get_endpoint_suffix(cloud, cloud_base_address)}" + + @staticmethod + def get_endpoint_suffix(cloud: PowerPlatformCloud, cloud_base_address: str) -> str: + return { + PowerPlatformCloud.LOCAL: "api.powerplatform.localhost", + PowerPlatformCloud.EXP: "api.exp.powerplatform.com", + PowerPlatformCloud.DEV: "api.dev.powerplatform.com", + PowerPlatformCloud.PRV: "api.prv.powerplatform.com", + PowerPlatformCloud.TEST: "api.test.powerplatform.com", + PowerPlatformCloud.PREPROD: "api.preprod.powerplatform.com", + PowerPlatformCloud.FIRST_RELEASE: "api.powerplatform.com", + PowerPlatformCloud.PROD: "api.powerplatform.com", + PowerPlatformCloud.GOV_FR: "api.gov.powerplatform.microsoft.us", + PowerPlatformCloud.GOV: "api.gov.powerplatform.microsoft.us", + PowerPlatformCloud.HIGH: "api.high.powerplatform.microsoft.us", + PowerPlatformCloud.DOD: "api.appsplatform.us", + PowerPlatformCloud.MOONCAKE: "api.powerplatform.partner.microsoftonline.cn", + PowerPlatformCloud.EX: "api.powerplatform.eaglex.ic.gov", + PowerPlatformCloud.RX: "api.powerplatform.microsoft.scloud", + PowerPlatformCloud.OTHER: cloud_base_address, + }.get(cloud, ValueError(f"Invalid cloud category value: {cloud}")) + + @staticmethod + def get_id_suffix_length(cloud: PowerPlatformCloud) -> int: + return ( + 2 + if cloud in {PowerPlatformCloud.FIRST_RELEASE, PowerPlatformCloud.PROD} + else 1 + ) diff --git a/libraries/Client/microsoft-agents-copilotstudio-client/pyproject.toml b/libraries/Client/microsoft-agents-copilotstudio-client/pyproject.toml new file mode 100644 index 00000000..64f848c5 --- /dev/null +++ b/libraries/Client/microsoft-agents-copilotstudio-client/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "microsoft-agents-copilotstudio-client" +version = "0.0.0a1" +description = "A client library for Microsoft Agents" +authors = [{name = "Microsoft Corporation"}] +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = [ + "microsoft-agents-core", +] + +[project.urls] +"Homepage" = "https://github.com/yourusername/microsoft-agents-client" diff --git a/test_samples/copilot_studio_client_sample/app.py b/test_samples/copilot_studio_client_sample/app.py new file mode 100644 index 00000000..bfd9b750 --- /dev/null +++ b/test_samples/copilot_studio_client_sample/app.py @@ -0,0 +1,62 @@ +import asyncio +from dotenv import load_dotenv +from os import environ, path +from msal import PublicClientApplication + +from microsoft.agents.copilotstudio.client import ConnectionSettings, CopilotClient + +from msal_cache_plugin import get_msal_token_cache +from chat_console_service import ChatConsoleService +from config import McsConnectionSettings + +load_dotenv() +connection_settings = McsConnectionSettings() + + +def aquire_token(mcs_settings: McsConnectionSettings, cache_path: str) -> str: + cache = get_msal_token_cache(cache_path) + app = PublicClientApplication( + mcs_settings.app_client_id, + authority=f"https://login.microsoftonline.com/{mcs_settings.tenant_id}", + token_cache=cache, + ) + + token_scopes = ["https://api.powerplatform.com/.default"] + + accounts = app.get_accounts() + + if accounts: + # If so, you could then somehow display these accounts and let end user choose + chosen = accounts[0] + result = app.acquire_token_silent(scopes=token_scopes, account=chosen) + else: + # At this point, you can save you can update your cache if you are using token caching + # check result variable, if its None then you should interactively acquire a token + # So no suitable token exists in cache. Let's get a new one from Microsoft Entra. + result = app.acquire_token_interactive(scopes=token_scopes) + + if "access_token" in result: + return result["access_token"] + else: + print(result.get("error")) + print(result.get("error_description")) + print(result.get("correlation_id")) # You may need this when reporting a bug + raise Exception("Authentication with the Public Application failed") + + +def create_mcs_client(connection_settings: ConnectionSettings) -> CopilotClient: + token = aquire_token( + connection_settings, + environ.get("TOKEN_CACHE_PATH") + or path.join(path.dirname(__file__), "bin/token_cache.bin"), + ) + return CopilotClient(connection_settings, token) + + +loop = asyncio.get_event_loop() +try: + loop.run_until_complete( + ChatConsoleService(create_mcs_client(connection_settings)).start_service() + ) +finally: + loop.close() diff --git a/test_samples/copilot_studio_client_sample/chat_console_service.py b/test_samples/copilot_studio_client_sample/chat_console_service.py new file mode 100644 index 00000000..ab749f8a --- /dev/null +++ b/test_samples/copilot_studio_client_sample/chat_console_service.py @@ -0,0 +1,46 @@ +from microsoft.agents.copilotstudio.client import CopilotClient +from microsoft.agents.core.models import Activity, ActivityTypes + + +class ChatConsoleService: + + def __init__(self, copilot_client: CopilotClient): + self._copilot_client = copilot_client + + async def start_service(self): + print("bot> ") + + # Attempt to connect to the copilot studio hosted bot here + # if successful, this will loop though all events that the Copilot Studio bot sends to the client setup the conversation. + async for activity in self._copilot_client.start_conversation(): + if not activity: + raise Exception("ChatConsoleService.start_service: Activity is None") + + self._print_activity(activity) + + # Once we are connected and have initiated the conversation, begin the message loop with the Console. + while True: + question = input("user> ") + + # Send the user input to the Copilot Studio bot and await the response. + # In this case we are not sending a conversation ID, as the bot is already connected by "StartConversationAsync", a conversation ID is persisted by the underlying client. + async for bot_activity in self._copilot_client.ask_question(question): + self._print_activity(bot_activity) + + @staticmethod + def _print_activity(activity: Activity): + if activity.type == ActivityTypes.message: + if activity.text_format == "markdown": + print(activity.text) + if activity.suggested_actions and activity.suggested_actions.actions: + print("Suggested actions:") + for action in activity.suggested_actions.actions: + print(f" - {action.text}") + else: + print(activity.text) + elif activity.type == ActivityTypes.typing: + print(".") + elif activity.type == ActivityTypes.event: + print("+") + else: + print(f"Activity type: [{activity.type}]") diff --git a/test_samples/copilot_studio_client_sample/config.py b/test_samples/copilot_studio_client_sample/config.py new file mode 100644 index 00000000..73f4cca5 --- /dev/null +++ b/test_samples/copilot_studio_client_sample/config.py @@ -0,0 +1,46 @@ +from os import environ +from typing import Optional + +from microsoft.agents.copilotstudio.client import ( + ConnectionSettings, + PowerPlatformCloud, + BotType, +) + + +class McsConnectionSettings(ConnectionSettings): + def __init__( + self, + app_client_id: Optional[str] = None, + tenant_id: Optional[str] = None, + environment_id: Optional[str] = None, + bot_identifier: Optional[str] = None, + cloud: Optional[PowerPlatformCloud] = None, + copilot_bot_type: Optional[BotType] = None, + custom_power_platform_cloud: Optional[str] = None, + ) -> None: + self.app_client_id = app_client_id or environ.get("APP_CLIENT_ID") + self.tenant_id = tenant_id or environ.get("TENANT_ID") + + if not self.app_client_id: + raise ValueError("App Client ID must be provided") + if not self.tenant_id: + raise ValueError("Tenant ID must be provided") + + environment_id = environment_id or environ.get("ENVIRONMENT_ID") + bot_identifier = bot_identifier or environ.get("BOT_IDENTIFIER") + cloud = cloud or PowerPlatformCloud[environ.get("CLOUD", "UNKNOWN")] + copilot_bot_type = ( + copilot_bot_type or BotType[environ.get("COPILOT_BOT_TYPE", "PUBLISHED")] + ) + custom_power_platform_cloud = custom_power_platform_cloud or environ.get( + "CUSTOM_POWER_PLATFORM_CLOUD", None + ) + + super().__init__( + environment_id, + bot_identifier, + cloud, + copilot_bot_type, + custom_power_platform_cloud, + ) diff --git a/test_samples/copilot_studio_client_sample/msal_cache_plugin.py b/test_samples/copilot_studio_client_sample/msal_cache_plugin.py new file mode 100644 index 00000000..a3180a32 --- /dev/null +++ b/test_samples/copilot_studio_client_sample/msal_cache_plugin.py @@ -0,0 +1,30 @@ +import logging +import json + +from msal_extensions import ( + build_encrypted_persistence, + FilePersistence, + PersistedTokenCache, +) + + +def get_msal_token_cache( + cache_path: str, fallback_to_plaintext=True +) -> PersistedTokenCache: + + persistence = None + + # Note: This sample stores both encrypted persistence and plaintext persistence + # into same location, therefore their data would likely override with each other. + try: + persistence = build_encrypted_persistence(cache_path) + except: # pylint: disable=bare-except + # On Linux, encryption exception will be raised during initialization. + # On Windows and macOS, they won't be detected here, + # but will be raised during their load() or save(). + if not fallback_to_plaintext: + raise + logging.warning("Encryption unavailable. Opting in to plain text.") + persistence = FilePersistence(cache_path) + + return PersistedTokenCache(persistence)