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
33 changes: 16 additions & 17 deletions examples/agent365/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,30 @@
# agent365

Acquire an Agent 365 agent user token using an agent identity blueprint, an agent identity app ID, and an agent user.
Demonstrates passing `AgenticIdentity` directly to Teams API surfaces.

## Run
## Reactive Echo

Set these environment variables or add them to `.env`:
`src/main.py` mimics the echo example. Incoming messages are handled normally; the inbound service URL and agentic identity are carried by the context/API layer.

```bash
AGENT365_TENANT_ID=<tenant-id>
AGENT365_BLUEPRINT_CLIENT_ID=<agent-identity-blueprint-app-id>
AGENT365_BLUEPRINT_CLIENT_SECRET=<agent-identity-blueprint-secret>
AGENT365_AGENT_IDENTITY_APP_ID=<agent-identity-app-id>
AGENT365_AGENT_USER_ID=<agent-user-object-id>
```

Then run:
export CLIENT_ID=<agent-identity-blueprint-app-id>
export CLIENT_SECRET=<agent-identity-blueprint-secret>
export TENANT_ID=<tenant-id>

```bash
uv run --project examples/agent365 python src/main.py
```

By default this requests a Teams bot API token for `https://botapi.skype.com/.default`.
## Proactive API Send

To request another resource, set `AGENT365_SCOPE`, for example:
`src/proactive.py` shows both `app.send(..., agentic_identity=...)` and the lower-level conversation activity API. In both cases the API layer asks the auth provider for the right Agent ID token and uses it in the request header.

```bash
AGENT365_SCOPE=https://graph.microsoft.com/.default
export CLIENT_ID=<agent-identity-blueprint-app-id>
export CLIENT_SECRET=<agent-identity-blueprint-secret>
export TENANT_ID=<tenant-id>

uv run --project examples/agent365 python src/proactive.py \
<conversation-id> \
<agentic-app-id> \
<agentic-user-id>
```

You can use `AGENT365_AGENT_USER_UPN` instead of `AGENT365_AGENT_USER_ID`.
62 changes: 32 additions & 30 deletions examples/agent365/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,46 +4,48 @@
"""

import asyncio
import os
import logging
import re

from dotenv import load_dotenv
from microsoft_teams.api import AgenticIdentity, ClientCredentials
from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TokenManager
from microsoft_teams.api import MessageActivity
from microsoft_teams.api.activities.typing import TypingActivityInput
from microsoft_teams.apps import ActivityContext, App

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def get_required_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise ValueError(f"{name} must be set")
app = App()

return value

@app.on_message_pattern(re.compile(r"hello|hi|greetings"))
async def handle_greeting(ctx: ActivityContext[MessageActivity]) -> None:
"""Handle greeting messages using the inbound AgenticIdentity when present."""
await ctx.reply("Hello! How can I assist you today?")

async def main():
load_dotenv()

tenant_id = get_required_env("AGENT365_TENANT_ID")
blueprint_client_id = get_required_env("AGENT365_BLUEPRINT_CLIENT_ID")
blueprint_client_secret = get_required_env("AGENT365_BLUEPRINT_CLIENT_SECRET")
agentic_app_id = get_required_env("AGENT365_AGENTIC_APP_ID")
agentic_user_id = get_required_env("AGENT365_AGENTIC_USER_ID")
scope = os.getenv("AGENT365_SCOPE", AGENT_BOT_API_SCOPE)
@app.on_message
async def handle_message(ctx: ActivityContext[MessageActivity]):
"""Echo incoming messages using the inbound AgenticIdentity when present."""
logger.info("[Agent365 reactive] Message received: %s", ctx.activity.text)
logger.info("[Agent365 reactive] From: %s", ctx.activity.from_)
logger.info("[Agent365 reactive] Agentic identity: %s", ctx.activity.recipient.agentic_identity)

credentials = ClientCredentials(
client_id=blueprint_client_id,
client_secret=blueprint_client_secret,
tenant_id=tenant_id,
)
token_manager = TokenManager(credentials=credentials)
await ctx.reply(TypingActivityInput())

token = await token_manager.get_agentic_token(
scope,
AgenticIdentity(agentic_app_id=agentic_app_id, agentic_user_id=agentic_user_id, tenant_id=tenant_id),
)
if "react" in ctx.activity.text.lower():
await ctx.api.reactions.add(
conversation_id=ctx.activity.conversation.id,
activity_id=ctx.activity.id,
reaction_type="like",
)
await ctx.reply("Added a like reaction to your message.")
return

print(f"Acquired agent user token for {scope}")
print(f"Token preview: {str(token)[:20]}...")
if "reply" in ctx.activity.text.lower():
await ctx.reply("Hello! How can I assist you today?")
else:
await ctx.send(f"You said '{ctx.activity.text}'")


if __name__ == "__main__":
asyncio.run(main())
asyncio.run(app.start())
43 changes: 43 additions & 0 deletions examples/agent365/src/proactive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

import argparse
import asyncio
import logging

from microsoft_teams.api import MessageActivityInput
from microsoft_teams.apps import App

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


async def main():
parser = argparse.ArgumentParser(description="Send proactive messages using AgenticIdentity")
parser.add_argument("conversation_id", help="The Teams conversation ID to send messages to")
parser.add_argument("agentic_app_id", help="The concrete agent identity app/client ID")
parser.add_argument("agentic_user_id", help="The agent user object ID")
args = parser.parse_args()

app = App()
await app.initialize()

agentic_identity = app.get_agentic_identity(args.agentic_app_id, args.agentic_user_id)
sent = await app.send(
args.conversation_id,
"Hello from app.send with an AgenticIdentity.",
agentic_identity=agentic_identity,
)
logger.info("Sent activity through app.send. Activity ID: %s", sent.id)

api_sent = await app.api.conversations.activities(args.conversation_id).create(
MessageActivityInput(text="Hello from the conversation activity API with an AgenticIdentity."),
agentic_identity=agentic_identity,
)
logger.info("Sent activity through app.api. Activity ID: %s", api_sent.id)


if __name__ == "__main__":
asyncio.run(main())
63 changes: 58 additions & 5 deletions packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
Account,
ActivityBase,
ActivityParams,
AgenticIdentity,
ApiClient,
ClientCredentials,
ConversationAccount,
Expand Down Expand Up @@ -136,6 +137,7 @@ def __init__(self, **options: Unpack[AppOptions]):
self.options.default_connection_name,
self.http_client,
self._token_manager,
self._auth_provider,
self.options.api_client_settings,
self.cloud,
)
Expand Down Expand Up @@ -289,7 +291,14 @@ async def stop(self) -> None:
self._events.emit("error", ErrorEvent(error, context={"method": "stop"}))
raise

async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard):
async def send(
self,
conversation_id: str,
activity: str | ActivityParams | AdaptiveCard,
*,
service_url: Optional[str] = None,
agentic_identity: Optional[AgenticIdentity] = None,
) -> SentActivity:
"""Send an activity proactively to a conversation.

Sends to the exact conversation ID provided. For channel threads,
Expand All @@ -305,7 +314,7 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap

conversation_ref = ConversationReference(
channel_id="msteams",
service_url=self.api.service_url,
service_url=service_url or self.api.service_url,
bot=Account(id=self.id),
conversation=ConversationAccount(id=conversation_id),
)
Expand All @@ -317,28 +326,62 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap
else:
activity = activity

return await send_or_update_activity(self.api, activity, conversation_ref)
return await send_or_update_activity(
self.api,
activity,
conversation_ref,
agentic_identity=agentic_identity,
)

def get_agentic_identity(
self,
agentic_app_id: str,
agentic_user_id: str,
*,
tenant_id: Optional[str] = None,
agentic_app_blueprint_id: Optional[str] = None,
) -> AgenticIdentity:
"""Get an Agent ID identity for API calls."""
resolved_tenant_id = tenant_id or (self.credentials.tenant_id if self.credentials else None)
if resolved_tenant_id is None:
raise ValueError("tenant_id is required to get an agentic identity")

return AgenticIdentity(
agentic_app_id=agentic_app_id,
agentic_user_id=agentic_user_id,
tenant_id=resolved_tenant_id,
agentic_app_blueprint_id=agentic_app_blueprint_id,
)

@overload
async def reply(
self,
conversation_id: str,
message_id: str,
activity: str | ActivityParams | AdaptiveCard,
*,
service_url: Optional[str] = None,
agentic_identity: Optional[AgenticIdentity] = None,
) -> SentActivity: ...

@overload
async def reply(
self,
conversation_id: str,
message_id: str | ActivityParams | AdaptiveCard,
*,
service_url: Optional[str] = None,
agentic_identity: Optional[AgenticIdentity] = None,
) -> SentActivity: ...

async def reply( # type: ignore[reportInconsistentOverload]
self,
conversation_id: str,
message_id: str | ActivityParams | AdaptiveCard = "",
activity: str | ActivityParams | AdaptiveCard | None = None,
*,
service_url: Optional[str] = None,
agentic_identity: Optional[AgenticIdentity] = None,
) -> SentActivity:
Comment on lines +382 to 385
"""Send an activity proactively to a conversation, optionally as a threaded reply.

Expand All @@ -359,9 +402,19 @@ async def reply( # type: ignore[reportInconsistentOverload]
if activity is not None:
if not isinstance(message_id, str):
raise TypeError("message_id must be a string when activity is provided")
return await self.send(to_threaded_conversation_id(conversation_id, message_id), activity)
return await self.send(
to_threaded_conversation_id(conversation_id, message_id),
activity,
service_url=service_url,
agentic_identity=agentic_identity,
)

return await self.send(conversation_id, message_id)
return await self.send(
conversation_id,
message_id,
service_url=service_url,
agentic_identity=agentic_identity,
)

def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None:
"""Add middleware to run on all activities."""
Expand Down
9 changes: 7 additions & 2 deletions packages/apps/src/microsoft_teams/apps/app_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@
from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment
from microsoft_teams.api.clients.user.params import GetUserTokenParams
from microsoft_teams.cards import AdaptiveCard
from microsoft_teams.common import Client, ClientOptions, LocalStorage, Storage
from microsoft_teams.common import Client, LocalStorage, Storage

if TYPE_CHECKING:
from .app_events import EventManager

from .auth_provider import AppAuthProvider
from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent
from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError
from .routing.activity_context import ActivityContext
Expand All @@ -47,6 +48,7 @@ def __init__(
default_connection_name: str,
http_client: Client,
token_manager: TokenManager,
auth_provider: AppAuthProvider,
api_client_settings: Optional[ApiClientSettings],
cloud: CloudEnvironment = PUBLIC,
) -> None:
Expand All @@ -56,6 +58,7 @@ def __init__(
self.default_connection_name = default_connection_name
self.http_client = http_client
self.token_manager = token_manager
self.auth_provider = auth_provider
self.api_client_settings = api_client_settings
self.cloud = cloud

Expand Down Expand Up @@ -90,8 +93,10 @@ async def _build_context(
)
api_client = ApiClient(
service_url,
self.http_client.clone(ClientOptions(token=self.token_manager.get_bot_token)),
self.http_client,
self.api_client_settings,
auth_provider=self.auth_provider,
agentic_identity=activity.recipient.agentic_identity,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recipient is Optional[Account] & this will crash with AttributeError for any inbound activity without a recipient. Since _build_context runs for every activity, this could break the full flow.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm recipient is actually Account on ActivityBase so it should be okay.

)

# Check if user is signed in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,12 @@ async def send(
self._add_targeted_message_info_entity(activity)

ref = conversation_ref or self.conversation_ref
return await send_or_update_activity(self.api, activity, ref)
return await send_or_update_activity(
self.api,
activity,
ref,
agentic_identity=self.activity.recipient.agentic_identity,
)

async def reply(self, input: str | ActivityParams) -> SentActivity:
"""Send a message in the current conversation with a visual quote of the inbound message.
Expand Down
Loading
Loading