Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions examples/graph/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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"
Expand All @@ -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"
)

Expand Down
26 changes: 25 additions & 1 deletion packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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")

Expand Down Expand Up @@ -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))
21 changes: 7 additions & 14 deletions packages/apps/src/microsoft_teams/apps/routing/activity_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/apps/src/microsoft_teams/apps/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
18 changes: 18 additions & 0 deletions packages/apps/src/microsoft_teams/apps/utils/graph.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 8 additions & 8 deletions packages/apps/tests/test_activity_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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:
Expand All @@ -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


Expand Down
75 changes: 71 additions & 4 deletions packages/apps/tests/test_optional_graph_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Loading