diff --git a/examples/graph/src/main.py b/examples/graph/src/main.py index 3f77f364..dad84d1c 100644 --- a/examples/graph/src/main.py +++ b/examples/graph/src/main.py @@ -152,6 +152,51 @@ async def handle_emails_command(ctx: ActivityContext[MessageActivity]): await ctx.send(f"❌ Failed to get your emails: {str(e)}") +@app.on_message_pattern("app-users ctx") +async def handle_app_users_ctx_command(ctx: ActivityContext[MessageActivity]): + """List users using ctx.app_graph (app-only, no sign-in required).""" + try: + users = await ctx.app_graph.users.get() + + if users and users.value: + user_list = "👥 **Organization Users**\n\n*Fetched using `ctx.app_graph`*\n\n" + for i, user in enumerate(users.value[:5], 1): + user_list += f"**{i}.** {user.display_name or 'N/A'} ({user.user_principal_name or 'N/A'})\n\n" + await ctx.send(user_list) + else: + await ctx.send("No users found.") + + except Exception as e: + await ctx.send( + f"❌ Failed to list users: {e}\n\n" + "Ensure the app has **User.Read.All** application permission granted " + "in Azure Portal > App registrations > API permissions, and that an admin has consented." + ) + + +@app.on_message_pattern("app-users") +async def handle_app_users_command(ctx: ActivityContext[MessageActivity]): + """List users using app.get_app_graph() (app-only, no sign-in required).""" + try: + graph = app.get_app_graph() + users = await graph.users.get() + + if users and users.value: + user_list = "👥 **Organization Users**\n\n*Fetched using `app.get_app_graph()`*\n\n" + for i, user in enumerate(users.value[:5], 1): + user_list += f"**{i}.** {user.display_name or 'N/A'} ({user.user_principal_name or 'N/A'})\n\n" + await ctx.send(user_list) + else: + await ctx.send("No users found.") + + except Exception as e: + await ctx.send( + f"❌ Failed to list users: {e}\n\n" + "Ensure the app has **User.Read.All** application permission granted " + "in Azure Portal > App registrations > API permissions, and that an admin has consented." + ) + + @app.on_message_pattern("help") async def handle_help_command(ctx: ActivityContext[MessageActivity]): """Handle help command.""" @@ -164,6 +209,8 @@ async def handle_help_command(ctx: ActivityContext[MessageActivity]): "• **signout** - Sign out of your account\n\n" "• **profile** - View your Microsoft profile information\n\n" "• **emails** - List your 5 most recent emails\n\n" + "• **app-users** - List org users via app.get_app_graph() (no sign-in needed)\n\n" + "• **app-users ctx** - List org users via ctx.app_graph (no sign-in needed)\n\n" "• **help** - Show this help message\n\n" "**Getting Started:**\n\n" "1. Type `signin` to authenticate\n\n" @@ -189,6 +236,8 @@ async def handle_default_message(ctx: ActivityContext[MessageActivity]): "• **signout** - Sign out\n\n" "• **profile** - Show your profile information\n\n" "• **emails** - List your recent emails\n\n" + "• **app-users** - List org users via app.get_app_graph()\n\n" + "• **app-users ctx** - List org users via ctx.app_graph\n\n" "• **help** - Show detailed help with technical info" ) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index f678910d..ca3247b4 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -7,7 +7,7 @@ import importlib.metadata import logging import os -from typing import Any, Awaitable, Callable, List, Optional, TypeVar, Union, Unpack, cast, overload +from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Optional, TypeVar, Union, Unpack, cast, overload from dependency_injector import providers from dotenv import find_dotenv, load_dotenv @@ -24,10 +24,14 @@ ManagedIdentityCredentials, MessageActivityInput, TokenCredentials, + TokenProtocol, ) from microsoft_teams.cards import AdaptiveCard from microsoft_teams.common import Client, ClientOptions, EventEmitter, LocalStorage +if TYPE_CHECKING: + from msgraph.graph_service_client import GraphServiceClient + from .activity_sender import ActivitySender from .app_events import EventManager from .app_oauth import OauthHandlers @@ -52,6 +56,7 @@ from .routing import ActivityHandlerMixin, ActivityRouter from .routing.activity_context import ActivityContext from .token_manager import TokenManager +from .utils import create_graph_client version = importlib.metadata.version("microsoft-teams-apps") @@ -519,3 +524,22 @@ async def _stop_plugins(self) -> None: async def _get_bot_token(self): return await self._token_manager.get_bot_token() + + async def _get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]: + return await self._token_manager.get_graph_token(tenant_id) + + def get_app_graph(self, tenant_id: Optional[str] = None) -> "GraphServiceClient": + """ + Get a Microsoft Graph client configured with the app's token. + + This client can be used for app-only operations that don't require user context. + For multi-tenant apps, pass a tenant_id to get a tenant-specific token. + + Args: + tenant_id: Optional tenant ID. If not provided, uses the app's default tenant. + + Raises: + ImportError: If the graph dependencies are not installed. + + """ + return create_graph_client(lambda: self._get_graph_token(tenant_id)) diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 9f273a19..5dfb9296 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -37,6 +37,7 @@ from microsoft_teams.common.http.client_token import Token from ..activity_sender import ActivitySender +from ..utils import create_graph_client if TYPE_CHECKING: from msgraph.graph_service_client import GraphServiceClient @@ -45,18 +46,6 @@ logger = logging.getLogger(__name__) -def _get_graph_client(token: Token): - """Lazy import and call get_graph_client when needed.""" - try: - from microsoft_teams.graph import get_graph_client - - return get_graph_client(token) - except ImportError as exc: - raise ImportError( - "Graph functionality not available. Install with 'pip install microsoft-teams-apps[graph]'" - ) from exc - - @dataclass class SignInOptions: """Options for the signin method.""" @@ -134,7 +123,9 @@ def user_graph(self) -> "GraphServiceClient": if self._user_graph is None: try: user_token = JsonWebToken(self.user_token) - self._user_graph = _get_graph_client(user_token) + self._user_graph = create_graph_client(user_token) + except ImportError: + raise except Exception as e: self.logger.error(f"Failed to create user graph client: {e}") raise RuntimeError(f"Failed to create user graph client: {e}") from e @@ -156,7 +147,9 @@ def app_graph(self) -> "GraphServiceClient": """ if self._app_graph is None: try: - self._app_graph = _get_graph_client(self._app_token) + self._app_graph = create_graph_client(self._app_token) + except ImportError: + raise except Exception as e: self.logger.error(f"Failed to create app graph client: {e}") raise RuntimeError(f"Failed to create app graph client: {e}") from e diff --git a/packages/apps/src/microsoft_teams/apps/utils/__init__.py b/packages/apps/src/microsoft_teams/apps/utils/__init__.py index 0daf370b..64c314f5 100644 --- a/packages/apps/src/microsoft_teams/apps/utils/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/utils/__init__.py @@ -4,6 +4,7 @@ """ from .activity_utils import extract_tenant_id +from .graph import create_graph_client from .retry import RetryOptions, retry -__all__ = ["extract_tenant_id", "retry", "RetryOptions"] +__all__ = ["create_graph_client", "extract_tenant_id", "retry", "RetryOptions"] diff --git a/packages/apps/src/microsoft_teams/apps/utils/graph.py b/packages/apps/src/microsoft_teams/apps/utils/graph.py new file mode 100644 index 00000000..e96c47ab --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/utils/graph.py @@ -0,0 +1,18 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from microsoft_teams.common.http.client_token import Token + + +def create_graph_client(token: Token): + """Lazy import and create a Graph client with the given token.""" + try: + from microsoft_teams.graph import get_graph_client + + return get_graph_client(token) + except ImportError as exc: + raise ImportError( + "Graph functionality not available. Install with 'pip install microsoft-teams-apps[graph]'" + ) from exc diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 96fdfbd9..4e4fa429 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -302,11 +302,11 @@ def test_user_graph_raises_when_no_user_token(self) -> None: _ = ctx.user_graph def test_user_graph_raises_runtime_error_when_graph_import_fails(self) -> None: - """user_graph raises RuntimeError when _get_graph_client raises ImportError.""" + """user_graph raises RuntimeError when create_graph_client raises ImportError.""" ctx, _ = _create_activity_context(is_signed_in=True, user_token="some.jwt.token") with patch( - "microsoft_teams.apps.routing.activity_context._get_graph_client", + "microsoft_teams.apps.routing.activity_context.create_graph_client", side_effect=ImportError("graph not installed"), ): with pytest.raises(RuntimeError, match="Failed to create user graph client"): @@ -316,15 +316,15 @@ def test_user_graph_raises_runtime_error_when_graph_import_fails(self) -> None: class TestActivityContextAppGraph: """Tests for ActivityContext.app_graph property.""" - def test_app_graph_raises_runtime_error_when_graph_import_fails(self) -> None: - """app_graph raises RuntimeError when _get_graph_client raises ImportError.""" + def test_app_graph_raises_import_error_when_graph_not_installed(self) -> None: + """app_graph raises ImportError when graph dependencies are not installed.""" ctx, _ = _create_activity_context() with patch( - "microsoft_teams.apps.routing.activity_context._get_graph_client", + "microsoft_teams.apps.routing.activity_context.create_graph_client", side_effect=ImportError("graph not installed"), ): - with pytest.raises(RuntimeError, match="Failed to create app graph client"): + with pytest.raises(ImportError, match="graph not installed"): _ = ctx.app_graph def test_app_graph_returns_cached_client_on_second_access(self) -> None: @@ -333,14 +333,14 @@ def test_app_graph_returns_cached_client_on_second_access(self) -> None: ctx, _ = _create_activity_context() with patch( - "microsoft_teams.apps.routing.activity_context._get_graph_client", + "microsoft_teams.apps.routing.activity_context.create_graph_client", return_value=mock_graph_client, ): first = ctx.app_graph second = ctx.app_graph assert first is second - # _get_graph_client should only have been called once (caching) + # create_graph_client should only have been called once (caching) assert ctx._app_graph is mock_graph_client diff --git a/packages/apps/tests/test_optional_graph_dependencies.py b/packages/apps/tests/test_optional_graph_dependencies.py index e646df62..6d085929 100644 --- a/packages/apps/tests/test_optional_graph_dependencies.py +++ b/packages/apps/tests/test_optional_graph_dependencies.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from microsoft_teams.apps.routing.activity_context import ActivityContext @@ -49,8 +49,8 @@ def mock_import(name: str, *args: Any, **kwargs: Any) -> Any: with patch("builtins.__import__", side_effect=mock_import): activity_context = self._create_activity_context() - # app_graph should raise RuntimeError when graph dependencies are not available - with pytest.raises(RuntimeError, match="Failed to create app graph client"): + # app_graph should raise ImportError when graph dependencies are not available + with pytest.raises(ImportError, match="Graph functionality not available"): _ = activity_context.app_graph def test_app_graph_property_with_graph_available(self) -> None: @@ -123,6 +123,73 @@ def test_app_graph_property_no_token(self) -> None: app_token=None, # No app token ) - # app_graph should raise ValueError when no app token is available + # app_graph should raise RuntimeError when no app token is available with pytest.raises(RuntimeError, match="Token cannot be None"): _ = activity_context.app_graph + + +class TestAppGetAppGraph: + """Test App.get_app_graph method.""" + + def _create_app(self): + from microsoft_teams.apps import App, AppOptions + + return App(**AppOptions(client_id="test-id", client_secret="test-secret")) + + def test_get_app_graph_raises_import_error_when_graph_not_installed(self) -> None: + """get_app_graph raises ImportError when graph dependencies are not available.""" + app = self._create_app() + + with patch( + "microsoft_teams.apps.app.create_graph_client", + side_effect=ImportError("graph not installed"), + ): + with pytest.raises(ImportError): + _ = app.get_app_graph() + + def test_get_app_graph_returns_new_client_each_call(self) -> None: + """get_app_graph returns a new client on every call (no caching).""" + app = self._create_app() + + mock_client_1 = MagicMock() + mock_client_2 = MagicMock() + side_effects = [mock_client_1, mock_client_2] + + with patch( + "microsoft_teams.apps.app.create_graph_client", + side_effect=side_effects, + ): + first = app.get_app_graph() + second = app.get_app_graph() + + assert first is mock_client_1 + assert second is mock_client_2 + assert first is not second + + def test_get_app_graph_passes_tenant_id(self) -> None: + """get_app_graph passes the tenant_id through to the token factory callable.""" + app = self._create_app() + + mock_client = MagicMock() + captured_token_arg = [] + + def capture_token(token): + captured_token_arg.append(token) + return mock_client + + with patch( + "microsoft_teams.apps.app.create_graph_client", + side_effect=capture_token, + ): + app.get_app_graph(tenant_id="my-tenant-id") + + assert len(captured_token_arg) == 1 + # token arg should be a callable (lambda) + assert callable(captured_token_arg[0]) + + # Verify the lambda invokes _get_graph_token with the correct tenant_id + with patch.object(app, "_get_graph_token", new=AsyncMock(return_value=None)) as mock_get_token: + import asyncio + + asyncio.run(captured_token_arg[0]()) + mock_get_token.assert_called_once_with("my-tenant-id")