diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py index a55001cc..f7e5d05e 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/client/channel_factory_protocol.py @@ -9,5 +9,6 @@ class ChannelFactoryProtocol(Protocol): + def create_channel(self, token_access: AccessTokenProviderBase) -> ChannelProtocol: - pass + raise NotImplementedError("create_channel must be implemented by subclasses") diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py index 4d4b18ab..8ade5445 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/_graph.py @@ -9,6 +9,7 @@ """ from typing import Any +from urllib.parse import urlparse from kiota_abstractions.request_information import RequestInformation from kiota_abstractions.authentication import AuthenticationProvider @@ -17,11 +18,15 @@ from microsoft_agents.hosting.core import ( AgentApplication, + Authorization, TurnContext, + AccessTokenProviderBase, ) +_DEFAULT_GRAPH_BASE_URL = "https://graph.microsoft.com/v1.0" -class _SDKAuthenticationProvider(AuthenticationProvider): + +class _SDKUserAuthenticationProvider(AuthenticationProvider): """Kiota authentication provider backed by the agent's authorization. Acquires an access token for the current turn via @@ -31,17 +36,17 @@ class _SDKAuthenticationProvider(AuthenticationProvider): def __init__( self, - app: AgentApplication, + auth: Authorization, context: TurnContext, handler_name: str | None = None, ): """Capture the context needed to resolve a token at request time. - :param app: The agent application whose authorization issues tokens. + :param auth: The agent application's authorization. :param context: The current turn context. - :param handler_name: The auth handler name used to acquire the token. + :param handler_name: Optional name of the handler to use for authentication. """ - self._app = app + self._auth = auth self._context = context self._handler_name = handler_name @@ -59,17 +64,55 @@ async def authenticate_request( if additional_authentication_context is None: additional_authentication_context = {} - token_response = await self._app.auth.get_token( - self._context, self._handler_name + token_response = await self._auth.get_token(self._context, self._handler_name) + if token_response and token_response.token: + request.headers.add("Authorization", f"Bearer {token_response.token}") + + +class _SDKAuthenticationProvider(AuthenticationProvider): + + def __init__( + self, + token_provider: AccessTokenProviderBase, + resource_url: str, + scopes: list[str], + ): + """Capture the context needed to resolve a token at request time. + + :param token_provider: The access token provider for the agent application. + :param resource_url: The resource URL for which to acquire the token. + :param scopes: The scopes for which to acquire the token. + """ + self._token_provider = token_provider + self._resource_url = resource_url + self._scopes = scopes + + async def authenticate_request( + self, + request: RequestInformation, + additional_authentication_context: dict[str, Any] | None = None, + ) -> None: + """Attach a bearer token to the outgoing Graph request. + + :param request: The request to authenticate. + :param additional_authentication_context: Optional Kiota authentication + context; unused but accepted to satisfy the provider interface. + """ + if additional_authentication_context is None: + additional_authentication_context = {} + + token = await self._token_provider.get_access_token( + self._resource_url, self._scopes ) - if token_response: - request.headers["Authorization"] = f"Bearer {token_response.token}" + if token: + request.headers.add("Authorization", f"Bearer {token}") -def _create_graph_service_client( +def _create_user_graph_service_client( app: AgentApplication, context: TurnContext, handler_name: str | None = None, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, ) -> GraphServiceClient: """Create a Graph client authenticated for the current turn. @@ -77,10 +120,74 @@ def _create_graph_service_client( :param context: The current turn context. :param handler_name: Optional auth handler name used to acquire the token. :return: A :class:`GraphServiceClient` that authenticates each request via - the agent's authorization. + the agent's connections. """ - return GraphServiceClient( - request_adapter=GraphRequestAdapter( - _SDKAuthenticationProvider(app, context, handler_name) - ) + adapter = GraphRequestAdapter( + _SDKUserAuthenticationProvider(app.auth, context, handler_name) ) + adapter.base_url = graph_base_url.rstrip("/") + "/" + return GraphServiceClient(request_adapter=adapter) + + +def _create_app_graph_service_client( + token_provider: AccessTokenProviderBase, + graph_base_url: str, +) -> GraphServiceClient: + """Create a Graph client authenticated for the agent application. + + :param token_provider: The access token provider for the agent application. + :param graph_base_url: The base URL for the Graph API. + :return: A :class:`GraphServiceClient` that authenticates each request via + the token provider. + """ + url_parsed = urlparse(graph_base_url) + resource_url = f"{url_parsed.scheme}://{url_parsed.netloc}" + scopes = [f"{resource_url}/.default"] + request_adapter = GraphRequestAdapter( + _SDKAuthenticationProvider(token_provider, resource_url, scopes) + ) + request_adapter.base_url = graph_base_url.rstrip("/") + "/" + return GraphServiceClient(request_adapter=request_adapter) + + +def _common_get_app_graph_client( + app: AgentApplication, + context: TurnContext, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, +) -> GraphServiceClient: + """Get a Graph client authenticated for the agent application. + + :param app: The agent application whose authorization issues tokens. + :param context: The current turn context. + :param graph_base_url: The base URL for the Graph API. + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's connections. + """ + if not context.identity: + raise ValueError("TurnContext.identity is required to get a Graph client.") + token_provider = app.connection_manager.get_token_provider( + context.identity, context.activity.service_url + ) + return _create_app_graph_service_client(token_provider, graph_base_url) + + +def _common_get_app_graph_client_for_connection( + app: AgentApplication, + connection_name: str | None = None, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, +) -> GraphServiceClient: + """Get a Graph client authenticated for the agent application. + + :param app: The agent application whose authorization issues tokens. + :param connection_name: Optional connection name to select a specific token provider. + :param graph_base_url: The base URL for the Graph API. + :return: A :class:`GraphServiceClient` that authenticates each request via + the token provider from the connection. + """ + token_provider: AccessTokenProviderBase + if not connection_name: + token_provider = app.connection_manager.get_default_connection() + else: + token_provider = app.connection_manager.get_connection(connection_name) + + return _create_app_graph_service_client(token_provider, graph_base_url) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py index 68f62054..3070843b 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_agent_extension.py @@ -50,7 +50,13 @@ from .task_module import TaskModule from .team import Team -from ._graph import _create_graph_service_client +from ._graph import ( + _DEFAULT_GRAPH_BASE_URL, + _create_user_graph_service_client, + _common_get_app_graph_client, + _common_get_app_graph_client_for_connection, +) + from ._teams_api_client import ( _get_teams_api_client, _set_teams_api_client, @@ -298,12 +304,44 @@ def get_teams_api_client(self, context: TurnContext) -> ApiClient: return _get_teams_api_client(context) def get_graph_client( - self, context: TurnContext, handler_name: str | None = None + self, + context: TurnContext, + handler_name: str | None = None, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, ) -> GraphServiceClient: """Get the Graph Service client. :param context: The turn context. :param handler_name: The name of the handler. + :param graph_base_url: The base URL for the Microsoft Graph API. :return: The Graph Service client. """ - return _create_graph_service_client(self._app, context, handler_name) + return _create_user_graph_service_client( + self._app, context, handler_name, graph_base_url=graph_base_url + ) + + def get_app_graph_client( + self, context: TurnContext, graph_base_url: str = _DEFAULT_GRAPH_BASE_URL + ) -> GraphServiceClient: + """Get the Graph Service client for the agent application. + + :param context: The turn context. + :param graph_base_url: The base URL for the Microsoft Graph API. + :return: The Graph Service client. + """ + return _common_get_app_graph_client( + self._app, context, graph_base_url=graph_base_url + ) + + def get_app_graph_client_for_connection( + self, connection_name: str, graph_base_url: str = _DEFAULT_GRAPH_BASE_URL + ) -> GraphServiceClient: + """Get the Graph Service client for the agent application using a specific connection. + + :param connection_name: The name of the connection to use for authentication. + :param graph_base_url: The base URL for the Microsoft Graph API. + :return: The Graph Service client. + """ + return _common_get_app_graph_client_for_connection( + self._app, connection_name=connection_name, graph_base_url=graph_base_url + ) diff --git a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py index 9685bfb6..81509842 100644 --- a/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py +++ b/libraries/microsoft-agents-hosting-msteams/microsoft_agents/hosting/msteams/teams_turn_context.py @@ -7,6 +7,8 @@ from typing import cast +from msgraph import GraphServiceClient + from microsoft_teams.api import ApiClient from microsoft_agents.activity import ( @@ -15,8 +17,17 @@ ActivityTreatmentTypes, ResourceResponse, ) -from microsoft_agents.hosting.core import AgentApplication, TurnContext +from microsoft_agents.hosting.core import ( + AgentApplication, + TurnContext, +) +from ._graph import ( + _DEFAULT_GRAPH_BASE_URL, + _create_user_graph_service_client, + _common_get_app_graph_client, + _common_get_app_graph_client_for_connection, +) from ._teams_api_client import _get_teams_api_client, _set_teams_api_client from .teams_activity import TeamsActivity @@ -122,3 +133,55 @@ async def send_targeted_activities( for activity in activities: TeamsTurnContext._make_targeted_activity(activity) return await self.send_activities(activities) + + def get_graph_client( + self, + handler_name: str | None = None, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, + ) -> GraphServiceClient: + """ + Get a Graph client for the current turn context. + + :param handler_name: Optional name of the handler to use for authentication. + :param graph_base_url: The base URL for the Microsoft Graph API. + + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's authorization. + """ + return _create_user_graph_service_client( + self._app, self, handler_name, graph_base_url=graph_base_url + ) + + def get_app_graph_client( + self, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, + ) -> GraphServiceClient: + """ + Get a Graph client for the current turn context. + + :param graph_base_url: The base URL for the Microsoft Graph API. + + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's connections. + """ + return _common_get_app_graph_client( + self._app, self, graph_base_url=graph_base_url + ) + + def get_app_graph_client_for_connection( + self, + connection_name: str, + graph_base_url: str = _DEFAULT_GRAPH_BASE_URL, + ) -> GraphServiceClient: + """ + Get a Graph client for the current turn context using a specific connection. + + :param connection_name: The name of the connection to use for authentication. + :param graph_base_url: The base URL for the Microsoft Graph API. + + :return: A :class:`GraphServiceClient` that authenticates each request via + the agent's connections. + """ + return _common_get_app_graph_client_for_connection( + self._app, connection_name, graph_base_url=graph_base_url + ) diff --git a/test_samples/hosting_msteams/conversation-agent/src/agent.py b/test_samples/hosting_msteams/conversation-agent/src/agent.py index 119dc113..87fe6cfa 100644 --- a/test_samples/hosting_msteams/conversation-agent/src/agent.py +++ b/test_samples/hosting_msteams/conversation-agent/src/agent.py @@ -51,7 +51,6 @@ from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() diff --git a/test_samples/hosting_msteams/graph-clients/README.md b/test_samples/hosting_msteams/graph-clients/README.md new file mode 100644 index 00000000..7e120230 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/README.md @@ -0,0 +1,32 @@ +# Teams Graph Clients Sample + +Demonstrates creating and using Microsoft Graph clients from Teams route +handlers: + +| Command | Graph client | Endpoint | +|---------|--------------|----------| +| `/appgraph apps` | app-only client | `GET /applications?$top=1&$select=id,appId,displayName` | +| `/usergraph me` | delegated user client | `GET /me?$select=id,displayName,userPrincipalName,mail` | +| `/signout` | delegated user auth handler | signs out the `GRAPH` handler | + +## Permissions + +The app-only command uses the service connection credentials and requires +Microsoft Graph application permissions such as `Application.Read.All` with +admin consent. + +The user command uses the configured Azure Bot OAuth connection and requires +delegated Microsoft Graph permissions such as `User.Read`. + +## Running + +1. Copy `env.TEMPLATE` to `.env`. +2. Fill in your bot/app credentials and configure the `GRAPH` OAuth connection. +3. Start the sample: + + ```powershell + python -m src.main + ``` + +The server listens on `http://localhost:3978/api/messages`. Expose it with a +dev tunnel and side-load the app manifest into Teams. diff --git a/test_samples/hosting_msteams/graph-clients/env.TEMPLATE b/test_samples/hosting_msteams/graph-clients/env.TEMPLATE new file mode 100644 index 00000000..2f6bed10 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/env.TEMPLATE @@ -0,0 +1,8 @@ +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET= +CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID= + +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__OBOCONNECTIONNAME=SERVICE_CONNECTION +AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__GRAPH__SETTINGS__AZUREBOTOAUTHCONNECTIONNAME= + +LOGGING__LOGLEVEL__microsoft_agents.hosting.core=INFO \ No newline at end of file diff --git a/test_samples/hosting_msteams/graph-clients/pyproject.toml b/test_samples/hosting_msteams/graph-clients/pyproject.toml new file mode 100644 index 00000000..53387405 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "graph-clients" +version = "0.1.0" +description = "Teams Graph clients sample — demonstrates app-only and delegated Microsoft Graph clients" +authors = [{name = "Microsoft Corporation"}] +license = "MIT" +requires-python = ">=3.11" +dependencies = [ + "microsoft-agents-activity", + "microsoft-agents-hosting-core", + "microsoft-agents-authentication-msal", + "microsoft-agents-hosting-aiohttp", + "microsoft-agents-hosting-msteams", + "python-dotenv", + "aiohttp", +] diff --git a/test_samples/hosting_msteams/graph-clients/src/__init__.py b/test_samples/hosting_msteams/graph-clients/src/__init__.py new file mode 100644 index 00000000..5b7f7a92 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. diff --git a/test_samples/hosting_msteams/graph-clients/src/agent.py b/test_samples/hosting_msteams/graph-clients/src/agent.py new file mode 100644 index 00000000..ab07b02f --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/agent.py @@ -0,0 +1,115 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Teams sample that creates and uses app-only and delegated Graph clients.""" + +import logging +from os import environ + +from dotenv import load_dotenv + +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + MemoryStorage, + TurnState, +) +from microsoft_agents.hosting.msteams import TeamsAgentExtension +from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext + +logger = logging.getLogger(__name__) +load_dotenv() + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, + adapter=ADAPTER, + authorization=AUTHORIZATION, + **agents_sdk_config, +) + +teams = TeamsAgentExtension[TurnState](AGENT_APP) + + +def _none_if_empty(value: str | None) -> str | None: + return value if value else None + + +@teams.message("/appgraph apps") +async def on_app_graph_apps(context: TeamsTurnContext, state: TurnState) -> None: + """Create an app-only Graph client and read one application registration.""" + graph = context.get_app_graph_client() + query = graph.applications.ApplicationsRequestBuilderGetQueryParameters( + top=1, + select=["id", "appId", "displayName"], + ) + config_cls = graph.applications.ApplicationsRequestBuilderGetRequestConfiguration + request_config = config_cls(query_parameters=query) + response = await graph.applications.get(request_config) + applications = response.value if response and response.value else [] + + if not applications: + await context.send_activity("No application registrations were returned.") + return + + app = applications[0] + display_name = _none_if_empty(app.display_name) or "(no display name)" + await context.send_activity( + "App Graph client returned an application:\n\n" + f"- Display name: {display_name}\n" + f"- App ID: {_none_if_empty(app.app_id) or '(none)'}\n" + f"- Object ID: {_none_if_empty(app.id) or '(none)'}" + ) + + +@teams.message("/usergraph me", auth_handlers=["GRAPH"]) +async def on_user_graph_me(context: TeamsTurnContext, state: TurnState) -> None: + """Create a delegated user Graph client and read the signed-in user.""" + graph = context.get_graph_client("GRAPH") + query = graph.me.UserItemRequestBuilderGetQueryParameters( + select=["id", "displayName", "userPrincipalName", "mail"], + ) + request_config = graph.me.UserItemRequestBuilderGetRequestConfiguration( + query_parameters=query + ) + user = await graph.me.get(request_config) + + if not user: + await context.send_activity("Microsoft Graph did not return a user.") + return + + display_name = _none_if_empty(user.display_name) or "(no display name)" + user_principal_name = _none_if_empty(user.user_principal_name) or "(no UPN)" + mail = _none_if_empty(user.mail) or "(no mail)" + await context.send_activity( + "User Graph client returned the signed-in user:\n\n" + f"- Display name: {display_name}\n" + f"- UPN: {user_principal_name}\n" + f"- Mail: {mail}\n" + f"- Object ID: {_none_if_empty(user.id) or '(none)'}" + ) + + +@teams.message("/signout") +async def on_sign_out(context: TeamsTurnContext, state: TurnState) -> None: + await AGENT_APP.auth.sign_out(context, "GRAPH") + await context.send_activity(f"Signed out of GRAPH.") + + +@teams.activity("message") +async def on_message(context: TeamsTurnContext, state: TurnState) -> None: + await context.send_activity( + "Graph clients sample commands:\n\n" + "- `/appgraph apps` - app-only Graph client call to /applications\n" + "- `/usergraph me` - delegated user Graph client call to /me\n" + "- `/signout` - sign out of the delegated Graph auth handler" + ) diff --git a/test_samples/hosting_msteams/graph-clients/src/main.py b/test_samples/hosting_msteams/graph-clients/src/main.py new file mode 100644 index 00000000..530d2574 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/main.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .agent import AGENT_APP, CONNECTION_MANAGER +from .start_server import start_server + + +if __name__ == "__main__": + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) diff --git a/test_samples/hosting_msteams/graph-clients/src/start_server.py b/test_samples/hosting_msteams/graph-clients/src/start_server.py new file mode 100644 index 00000000..97792f47 --- /dev/null +++ b/test_samples/hosting_msteams/graph-clients/src/start_server.py @@ -0,0 +1,32 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from os import environ + +from aiohttp.web import Application, Request, Response, run_app + +from microsoft_agents.hosting.aiohttp import ( + CloudAdapter, + jwt_authorization_middleware, + start_agent_process, +) +from microsoft_agents.hosting.core import AgentApplication + + +def start_server( + agent_application: AgentApplication, + auth_configuration, +) -> None: + async def entry_point(req: Request) -> Response: + agent: AgentApplication = req.app["agent_app"] + adapter: CloudAdapter = req.app["adapter"] + return await start_agent_process(req, agent, adapter) + + app = Application(middlewares=[jwt_authorization_middleware]) + app.router.add_post("/api/messages", entry_point) + app.router.add_get("/api/messages", lambda _: Response(status=200)) + app["agent_configuration"] = auth_configuration + app["agent_app"] = agent_application + app["adapter"] = agent_application.adapter + + run_app(app, host="localhost", port=int(environ.get("PORT", 3978))) diff --git a/test_samples/hosting_msteams/message-extensions/src/agent.py b/test_samples/hosting_msteams/message-extensions/src/agent.py index 2d7f9c33..1bb95ce6 100644 --- a/test_samples/hosting_msteams/message-extensions/src/agent.py +++ b/test_samples/hosting_msteams/message-extensions/src/agent.py @@ -44,7 +44,6 @@ from microsoft_agents.hosting.msteams import TeamsAgentExtension from microsoft_agents.hosting.msteams.teams_turn_context import TeamsTurnContext -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() diff --git a/test_samples/hosting_msteams/task-modules/src/agent.py b/test_samples/hosting_msteams/task-modules/src/agent.py index c4288035..4e24e043 100644 --- a/test_samples/hosting_msteams/task-modules/src/agent.py +++ b/test_samples/hosting_msteams/task-modules/src/agent.py @@ -35,7 +35,6 @@ from .card_loader import load_card_json -logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) load_dotenv() diff --git a/tests/hosting_msteams/test_graph_clients.py b/tests/hosting_msteams/test_graph_clients.py new file mode 100644 index 00000000..024a76f2 --- /dev/null +++ b/tests/hosting_msteams/test_graph_clients.py @@ -0,0 +1,164 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +"""Tests for Microsoft Graph client creation helpers in Teams hosting.""" + +from types import SimpleNamespace + +import pytest + +from .helpers import is_supported_version + +pytestmark = pytest.mark.skipif( + not is_supported_version, + reason="microsoft-agents-hosting-teams tests require Python 3.11+", +) + +if is_supported_version: + from kiota_abstractions.method import Method + from kiota_abstractions.request_information import RequestInformation + + from microsoft_agents.activity import TokenResponse + from microsoft_agents.hosting.msteams._graph import ( + _common_get_app_graph_client, + _common_get_app_graph_client_for_connection, + _create_app_graph_service_client, + _create_user_graph_service_client, + ) + + +class _RecordingAuthorization: + def __init__(self): + self.calls = [] + + async def get_token(self, context, handler_name): + self.calls.append((context, handler_name)) + return TokenResponse(token="delegated-token") + + +class _RecordingTokenProvider: + def __init__(self): + self.calls = [] + + async def get_access_token( + self, resource_url: str, scopes: list[str], force_refresh: bool = False + ) -> str: + self.calls.append((resource_url, scopes, force_refresh)) + return "app-token" + + +class _RecordingConnectionManager: + def __init__(self): + self.default_provider = _RecordingTokenProvider() + self.named_provider = _RecordingTokenProvider() + self.turn_provider = _RecordingTokenProvider() + self.calls = [] + + def get_token_provider(self, identity, service_url): + self.calls.append(("get_token_provider", identity, service_url)) + return self.turn_provider + + def get_connection(self, connection_name): + self.calls.append(("get_connection", connection_name)) + return self.named_provider + + def get_default_connection(self): + self.calls.append(("get_default_connection",)) + return self.default_provider + + +@pytest.mark.asyncio +async def test_delegated_graph_client_gets_token_from_authorization_handler(): + authorization = _RecordingAuthorization() + context = SimpleNamespace() + app = SimpleNamespace(auth=authorization) + graph = _create_user_graph_service_client(app, context, "GRAPH") + request = RequestInformation() + request.http_method = Method.GET + request.url = "https://graph.microsoft.com/v1.0/me" + + native_request = await graph.request_adapter.convert_to_native_async(request) + + assert authorization.calls == [(context, "GRAPH")] + assert "authorization" in native_request.headers + + +def test_delegated_graph_client_uses_custom_graph_base_url(): + authorization = _RecordingAuthorization() + context = SimpleNamespace() + app = SimpleNamespace(auth=authorization) + graph = _create_user_graph_service_client( + app, + context, + graph_base_url="https://graph.microsoft.us/v1.0", + ) + + assert graph.request_adapter.base_url == "https://graph.microsoft.us/v1.0/" + + +@pytest.mark.asyncio +async def test_app_graph_client_uses_default_scope_for_custom_graph_cloud(): + token_provider = _RecordingTokenProvider() + graph = _create_app_graph_service_client( + token_provider, + "https://graph.microsoft.us/v1.0", + ) + request = RequestInformation() + request.http_method = Method.GET + request.url = "https://graph.microsoft.us/v1.0/applications" + + native_request = await graph.request_adapter.convert_to_native_async(request) + + assert token_provider.calls == [ + ( + "https://graph.microsoft.us", + ["https://graph.microsoft.us/.default"], + False, + ) + ] + assert "authorization" in native_request.headers + + +def test_context_app_graph_client_resolves_connection_from_turn_identity_and_service_url(): + connection_manager = _RecordingConnectionManager() + app = SimpleNamespace(connection_manager=connection_manager) + identity = SimpleNamespace() + context = SimpleNamespace( + identity=identity, + activity=SimpleNamespace(service_url="https://smba.trafficmanager.net/teams/"), + ) + + graph = _common_get_app_graph_client(app, context) + + assert graph.request_adapter.base_url == "https://graph.microsoft.com/v1.0/" + assert connection_manager.calls == [ + ( + "get_token_provider", + identity, + "https://smba.trafficmanager.net/teams/", + ) + ] + + +def test_named_app_graph_client_uses_named_connection(): + connection_manager = _RecordingConnectionManager() + app = SimpleNamespace(connection_manager=connection_manager) + + graph = _common_get_app_graph_client_for_connection(app, "SERVICE_CONNECTION_2") + + assert graph.request_adapter.base_url == "https://graph.microsoft.com/v1.0/" + assert connection_manager.calls == [ + ("get_connection", "SERVICE_CONNECTION_2"), + ] + + +def test_default_app_graph_client_uses_default_connection_when_name_is_omitted(): + connection_manager = _RecordingConnectionManager() + app = SimpleNamespace(connection_manager=connection_manager) + + graph = _common_get_app_graph_client_for_connection(app, None) + + assert graph.request_adapter.base_url == "https://graph.microsoft.com/v1.0/" + assert connection_manager.calls == [ + ("get_default_connection",), + ]