diff --git a/README.md b/README.md
index 5068afedb..592ca9788 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,10 @@
A comprehensive SDK for building Microsoft Teams applications, bots, and AI agents using Python. This SDK provides a high-level framework with built-in Microsoft Graph integration, OAuth handling, and extensible plugin architecture.
+## Agent 365 Support
+
+Agent 365 support is being developed on this integration branch.
+
diff --git a/examples/agent365/README.md b/examples/agent365/README.md
new file mode 100644
index 000000000..e2602a3a3
--- /dev/null
+++ b/examples/agent365/README.md
@@ -0,0 +1,30 @@
+# agent365
+
+Demonstrates scoping Teams API clients with `AgenticIdentity`.
+
+## Reactive Echo
+
+`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
+export CLIENT_ID=
+export CLIENT_SECRET=
+export TENANT_ID=
+
+uv run --project examples/agent365 python src/main.py
+```
+
+## Proactive API Send
+
+`src/proactive.py` shows both `app.send(..., agentic_identity=...)` and a scoped lower-level conversation activity API client. In both cases the API layer asks the auth provider for the right Agent ID token and uses it in the request header.
+
+```bash
+export CLIENT_ID=
+export CLIENT_SECRET=
+export TENANT_ID=
+
+uv run --project examples/agent365 python src/proactive.py \
+ \
+ \
+
+```
diff --git a/examples/agent365/pyproject.toml b/examples/agent365/pyproject.toml
new file mode 100644
index 000000000..43fc1ee3d
--- /dev/null
+++ b/examples/agent365/pyproject.toml
@@ -0,0 +1,13 @@
+[project]
+name = "agent365"
+version = "0.1.0"
+description = "Agent 365 token example"
+readme = "README.md"
+requires-python = ">=3.11,<4.0"
+dependencies = [
+ "dotenv>=0.9.9",
+ "microsoft-teams-apps",
+]
+
+[tool.uv.sources]
+microsoft-teams-apps = { workspace = true }
diff --git a/examples/agent365/src/main.py b/examples/agent365/src/main.py
new file mode 100644
index 000000000..995c9503a
--- /dev/null
+++ b/examples/agent365/src/main.py
@@ -0,0 +1,162 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+import asyncio
+import logging
+import re
+
+from microsoft_teams.api import (
+ AgenticUserDeletedActivity,
+ AgenticUserDisabledActivity,
+ AgenticUserEnabledActivity,
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityUpdatedActivity,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserUndeletedActivity,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
+ AgentLifecycleEventActivity,
+ 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__)
+
+app = App()
+
+
+def _log_lifecycle_envelope(activity: AgentLifecycleEventActivity, handler_name: str) -> None:
+ logger.info(
+ "[Agent365 lifecycle:%s] name=%s value_type=%s event_type=%s channel_id=%s from=%s recipient_identity=%s",
+ handler_name,
+ activity.name,
+ activity.value_type,
+ activity.value.event_type,
+ activity.channel_id,
+ activity.from_.id,
+ activity.recipient.agentic_identity,
+ )
+ logger.info(
+ "[Agent365 lifecycle:%s] tenant_id=%s agentic_user_id=%s app_instance_id=%s blueprint_id=%s version=%s",
+ handler_name,
+ activity.value.tenant_id,
+ activity.value.agentic_user_id,
+ activity.value.agentic_app_instance_id,
+ activity.value.agent_identity_blueprint_id,
+ activity.value.version,
+ )
+
+
+@app.on_agent_lifecycle
+async def handle_agent_lifecycle(ctx: ActivityContext[AgentLifecycleEventActivity]) -> None:
+ """Log every Agent 365 agentLifecycle event."""
+ _log_lifecycle_envelope(ctx.activity, "all")
+ await ctx.next()
+
+
+@app.on_agentic_user_identity_created
+async def handle_agentic_user_identity_created(ctx: ActivityContext[AgenticUserIdentityCreatedActivity]) -> None:
+ """Log an agentic user identity creation event."""
+ activity = ctx.activity
+ _log_lifecycle_envelope(activity, "identity_created")
+ logger.info(
+ "[Agent365 lifecycle:identity_created] expiration_date_time=%s manager=%s",
+ activity.value.expiration_date_time,
+ activity.value.manager,
+ )
+
+
+@app.on_agentic_user_identity_updated
+async def handle_agentic_user_identity_updated(ctx: ActivityContext[AgenticUserIdentityUpdatedActivity]) -> None:
+ """Log an agentic user identity property update event."""
+ activity = ctx.activity
+ _log_lifecycle_envelope(activity, "identity_updated")
+ logger.info(
+ "[Agent365 lifecycle:identity_updated] updated_property=%s",
+ activity.value.updated_property,
+ )
+
+
+@app.on_agentic_user_manager_updated
+async def handle_agentic_user_manager_updated(ctx: ActivityContext[AgenticUserManagerUpdatedActivity]) -> None:
+ """Log an agentic user manager update event."""
+ activity = ctx.activity
+ _log_lifecycle_envelope(activity, "manager_updated")
+ logger.info("[Agent365 lifecycle:manager_updated] manager=%s", activity.value.manager)
+
+
+@app.on_agentic_user_enabled
+async def handle_agentic_user_enabled(ctx: ActivityContext[AgenticUserEnabledActivity]) -> None:
+ """Log an agentic user enabled event."""
+ _log_lifecycle_envelope(ctx.activity, "enabled")
+
+
+@app.on_agentic_user_disabled
+async def handle_agentic_user_disabled(ctx: ActivityContext[AgenticUserDisabledActivity]) -> None:
+ """Log an agentic user disabled event."""
+ _log_lifecycle_envelope(ctx.activity, "disabled")
+
+
+@app.on_agentic_user_deleted
+async def handle_agentic_user_deleted(ctx: ActivityContext[AgenticUserDeletedActivity]) -> None:
+ """Log an agentic user deleted event."""
+ activity = ctx.activity
+ _log_lifecycle_envelope(activity, "deleted")
+ logger.info("[Agent365 lifecycle:deleted] deletion_reason=%s", activity.value.deletion_reason)
+
+
+@app.on_agentic_user_undeleted
+async def handle_agentic_user_undeleted(ctx: ActivityContext[AgenticUserUndeletedActivity]) -> None:
+ """Log an agentic user undeleted event."""
+ _log_lifecycle_envelope(ctx.activity, "undeleted")
+
+
+@app.on_agentic_user_workload_onboarding_updated
+async def handle_agentic_user_workload_onboarding_updated(
+ ctx: ActivityContext[AgenticUserWorkloadOnboardingUpdatedActivity],
+) -> None:
+ """Log an agentic user workload onboarding update event."""
+ activity = ctx.activity
+ _log_lifecycle_envelope(activity, "workload_onboarding_updated")
+ logger.info(
+ "[Agent365 lifecycle:workload_onboarding_updated] workload_name=%s workload_onboarding_state=%s",
+ activity.value.workload_name,
+ activity.value.workload_onboarding_state,
+ )
+
+
+@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?")
+
+
+@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)
+
+ await ctx.reply(TypingActivityInput())
+
+ if "react" in ctx.activity.text.lower():
+ await ctx.api.conversations.add_reaction(
+ 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
+
+ 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(app.start())
diff --git a/examples/agent365/src/proactive.py b/examples/agent365/src/proactive.py
new file mode 100644
index 000000000..fcb57dbbc
--- /dev/null
+++ b/examples/agent365/src/proactive.py
@@ -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.from_agentic_identity(agentic_identity).conversations.create_activity(
+ args.conversation_id,
+ MessageActivityInput(text="Hello from the conversation activity API with an AgenticIdentity."),
+ )
+ logger.info("Sent activity through app.api. Activity ID: %s", api_sent.id)
+
+
+if __name__ == "__main__":
+ asyncio.run(main())
diff --git a/examples/formatted-messaging/src/main.py b/examples/formatted-messaging/src/main.py
index fffd0d9fe..f5caf065f 100644
--- a/examples/formatted-messaging/src/main.py
+++ b/examples/formatted-messaging/src/main.py
@@ -24,36 +24,40 @@ async def handle_message(ctx: ActivityContext[MessageActivity]):
text = ctx.activity.text.lower()
if "extended" in text:
- rich_content = "\n".join([
- "\n",
- "# Extended Markdown Demo",
- "",
- "## Table",
- "| Feature | Status |",
- "|---------|--------|",
- "| Tables | Supported |",
- "| Math | Supported |",
- "",
- "## Math",
- "$$E = mc^2$$",
- ])
+ rich_content = "\n".join(
+ [
+ "\n",
+ "# Extended Markdown Demo",
+ "",
+ "## Table",
+ "| Feature | Status |",
+ "|---------|--------|",
+ "| Tables | Supported |",
+ "| Math | Supported |",
+ "",
+ "## Math",
+ "$$E = mc^2$$",
+ ]
+ )
reply = MessageActivityInput(text=rich_content).with_text_format("extendedmarkdown")
await ctx.reply(reply)
elif "markdown" in text:
- md_content = "\n".join([
- "\n",
- "# Markdown Demo",
- "",
- "**Bold**, *italic*, and ~~strikethrough~~",
- "",
- "- Item one",
- "- Item two",
- "- Item three",
- "",
- "> This is a blockquote",
- "",
- "`inline code` and [a link](https://www.microsoft.com)",
- ])
+ md_content = "\n".join(
+ [
+ "\n",
+ "# Markdown Demo",
+ "",
+ "**Bold**, *italic*, and ~~strikethrough~~",
+ "",
+ "- Item one",
+ "- Item two",
+ "- Item three",
+ "",
+ "> This is a blockquote",
+ "",
+ "`inline code` and [a link](https://www.microsoft.com)",
+ ]
+ )
reply = MessageActivityInput(text=md_content).with_text_format("markdown")
await ctx.reply(reply)
elif "xml" in text:
@@ -64,14 +68,10 @@ async def handle_message(ctx: ActivityContext[MessageActivity]):
reply = MessageActivityInput(text=xml_content).with_text_format("xml")
await ctx.reply(reply)
elif "plain" in text:
- reply = MessageActivityInput(
- text="This is plain text with no formatting applied."
- ).with_text_format("plain")
+ reply = MessageActivityInput(text="This is plain text with no formatting applied.").with_text_format("plain")
await ctx.reply(reply)
else:
- await ctx.send(
- "Send **markdown**, **extended**, **xml**, or **plain** to see different text formats."
- )
+ await ctx.send("Send **markdown**, **extended**, **xml**, or **plain** to see different text formats.")
if __name__ == "__main__":
diff --git a/packages/api/src/microsoft_teams/api/activities/event/__init__.py b/packages/api/src/microsoft_teams/api/activities/event/__init__.py
index 56147f8e2..8d89421a9 100644
--- a/packages/api/src/microsoft_teams/api/activities/event/__init__.py
+++ b/packages/api/src/microsoft_teams/api/activities/event/__init__.py
@@ -7,6 +7,30 @@
from pydantic import Field
+from .agent_lifecycle import (
+ AgenticUserDeletedActivity,
+ AgenticUserDeletedValue,
+ AgenticUserDisabledActivity,
+ AgenticUserDisabledValue,
+ AgenticUserEnabledActivity,
+ AgenticUserEnabledValue,
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityCreatedValue,
+ AgenticUserIdentityUpdatedActivity,
+ AgenticUserIdentityUpdatedValue,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserManagerUpdatedValue,
+ AgenticUserUndeletedActivity,
+ AgenticUserUndeletedValue,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
+ AgenticUserWorkloadOnboardingUpdatedValue,
+ AgentLifecycleEventActivity,
+ AgentLifecycleEventActivityBase,
+ AgentLifecycleManager,
+ AgentLifecycleManagerRef,
+ AgentLifecycleUpdatedProperty,
+ AgentLifecycleValueBase,
+)
from .meeting_end import MeetingEndEventActivity
from .meeting_participant import MeetingParticipantEventActivity
from .meeting_participant_join import MeetingParticipantJoinEventActivity
@@ -21,6 +45,7 @@
MeetingEndEventActivity,
MeetingParticipantJoinEventActivity,
MeetingParticipantLeaveEventActivity,
+ AgentLifecycleEventActivity,
],
Field(discriminator="name"),
]
@@ -33,4 +58,26 @@
"MeetingParticipantLeaveEventActivity",
"ReadReceiptEventActivity",
"EventActivity",
+ "AgentLifecycleEventActivity",
+ "AgentLifecycleEventActivityBase",
+ "AgenticUserIdentityCreatedActivity",
+ "AgenticUserIdentityUpdatedActivity",
+ "AgenticUserManagerUpdatedActivity",
+ "AgenticUserEnabledActivity",
+ "AgenticUserDisabledActivity",
+ "AgenticUserDeletedActivity",
+ "AgenticUserUndeletedActivity",
+ "AgenticUserWorkloadOnboardingUpdatedActivity",
+ "AgentLifecycleManager",
+ "AgentLifecycleManagerRef",
+ "AgentLifecycleUpdatedProperty",
+ "AgentLifecycleValueBase",
+ "AgenticUserIdentityCreatedValue",
+ "AgenticUserIdentityUpdatedValue",
+ "AgenticUserManagerUpdatedValue",
+ "AgenticUserEnabledValue",
+ "AgenticUserDisabledValue",
+ "AgenticUserDeletedValue",
+ "AgenticUserUndeletedValue",
+ "AgenticUserWorkloadOnboardingUpdatedValue",
]
diff --git a/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/__init__.py b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/__init__.py
new file mode 100644
index 000000000..7ef17d8fb
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/__init__.py
@@ -0,0 +1,56 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from .activity import (
+ AgenticUserDeletedActivity,
+ AgenticUserDisabledActivity,
+ AgenticUserEnabledActivity,
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityUpdatedActivity,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserUndeletedActivity,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
+ AgentLifecycleEventActivity,
+ AgentLifecycleEventActivityBase,
+)
+from .value import (
+ AgenticUserDeletedValue,
+ AgenticUserDisabledValue,
+ AgenticUserEnabledValue,
+ AgenticUserIdentityCreatedValue,
+ AgenticUserIdentityUpdatedValue,
+ AgenticUserManagerUpdatedValue,
+ AgenticUserUndeletedValue,
+ AgenticUserWorkloadOnboardingUpdatedValue,
+ AgentLifecycleManager,
+ AgentLifecycleManagerRef,
+ AgentLifecycleUpdatedProperty,
+ AgentLifecycleValueBase,
+)
+
+__all__ = [
+ "AgentLifecycleEventActivity",
+ "AgentLifecycleEventActivityBase",
+ "AgenticUserIdentityCreatedActivity",
+ "AgenticUserIdentityUpdatedActivity",
+ "AgenticUserManagerUpdatedActivity",
+ "AgenticUserEnabledActivity",
+ "AgenticUserDisabledActivity",
+ "AgenticUserDeletedActivity",
+ "AgenticUserUndeletedActivity",
+ "AgenticUserWorkloadOnboardingUpdatedActivity",
+ "AgentLifecycleManager",
+ "AgentLifecycleManagerRef",
+ "AgentLifecycleUpdatedProperty",
+ "AgentLifecycleValueBase",
+ "AgenticUserIdentityCreatedValue",
+ "AgenticUserIdentityUpdatedValue",
+ "AgenticUserManagerUpdatedValue",
+ "AgenticUserEnabledValue",
+ "AgenticUserDisabledValue",
+ "AgenticUserDeletedValue",
+ "AgenticUserUndeletedValue",
+ "AgenticUserWorkloadOnboardingUpdatedValue",
+]
diff --git a/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/activity.py b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/activity.py
new file mode 100644
index 000000000..6a7fcc2d8
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/activity.py
@@ -0,0 +1,119 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from typing import Annotated, Literal, Union
+
+from pydantic import Field
+
+from ....models import ActivityBase, CustomBaseModel
+from .value import (
+ AgenticUserDeletedValue,
+ AgenticUserDisabledValue,
+ AgenticUserEnabledValue,
+ AgenticUserIdentityCreatedValue,
+ AgenticUserIdentityUpdatedValue,
+ AgenticUserManagerUpdatedValue,
+ AgenticUserUndeletedValue,
+ AgenticUserWorkloadOnboardingUpdatedValue,
+)
+
+
+class AgentLifecycleEventActivityBase(ActivityBase, CustomBaseModel):
+ """Base for Agent 365 ``agentLifecycle`` event activities.
+
+ These activities arrive from the ``System`` user on the ``agents`` channel with
+ ``type == "event"`` and ``name == "agentLifecycle"``. The ``value_type`` field
+ names the variant and ``value`` carries the typed payload.
+ """
+
+ type: Literal["event"] = "event"
+
+ name: Literal["agentLifecycle"] = "agentLifecycle"
+ """The name of the operation associated with an event activity."""
+
+
+class AgenticUserIdentityCreatedActivity(AgentLifecycleEventActivityBase):
+ """Fired when an agentic user identity is created."""
+
+ value_type: Literal["AgenticUserIdentityCreated"] = "AgenticUserIdentityCreated"
+ value: AgenticUserIdentityCreatedValue
+
+
+class AgenticUserIdentityUpdatedActivity(AgentLifecycleEventActivityBase):
+ """Fired when an agentic user identity property changes."""
+
+ value_type: Literal["AgenticUserIdentityUpdated"] = "AgenticUserIdentityUpdated"
+ value: AgenticUserIdentityUpdatedValue
+
+
+class AgenticUserManagerUpdatedActivity(AgentLifecycleEventActivityBase):
+ """Fired when an agentic user's manager changes."""
+
+ value_type: Literal["AgenticUserManagerUpdated"] = "AgenticUserManagerUpdated"
+ value: AgenticUserManagerUpdatedValue
+
+
+class AgenticUserEnabledActivity(AgentLifecycleEventActivityBase):
+ """Fired when an agentic user is enabled."""
+
+ value_type: Literal["AgenticUserEnabled"] = "AgenticUserEnabled"
+ value: AgenticUserEnabledValue
+
+
+class AgenticUserDisabledActivity(AgentLifecycleEventActivityBase):
+ """Fired when an agentic user is disabled."""
+
+ value_type: Literal["AgenticUserDisabled"] = "AgenticUserDisabled"
+ value: AgenticUserDisabledValue
+
+
+class AgenticUserDeletedActivity(AgentLifecycleEventActivityBase):
+ """Fired when an agentic user is deleted."""
+
+ value_type: Literal["AgenticUserDeleted"] = "AgenticUserDeleted"
+ value: AgenticUserDeletedValue
+
+
+class AgenticUserUndeletedActivity(AgentLifecycleEventActivityBase):
+ """Fired when a previously deleted agentic user is restored."""
+
+ value_type: Literal["AgenticUserUndeleted"] = "AgenticUserUndeleted"
+ value: AgenticUserUndeletedValue
+
+
+class AgenticUserWorkloadOnboardingUpdatedActivity(AgentLifecycleEventActivityBase):
+ """Fired when a workload onboarding state changes for an agentic user."""
+
+ value_type: Literal["AgenticUserWorkloadOnboardingUpdated"] = "AgenticUserWorkloadOnboardingUpdated"
+ value: AgenticUserWorkloadOnboardingUpdatedValue
+
+
+AgentLifecycleEventActivity = Annotated[
+ Union[
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityUpdatedActivity,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserEnabledActivity,
+ AgenticUserDisabledActivity,
+ AgenticUserDeletedActivity,
+ AgenticUserUndeletedActivity,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
+ ],
+ Field(discriminator="value_type"),
+]
+"""Union of all Agent 365 ``agentLifecycle`` event activities, discriminated by ``valueType``."""
+
+__all__ = [
+ "AgentLifecycleEventActivityBase",
+ "AgenticUserIdentityCreatedActivity",
+ "AgenticUserIdentityUpdatedActivity",
+ "AgenticUserManagerUpdatedActivity",
+ "AgenticUserEnabledActivity",
+ "AgenticUserDisabledActivity",
+ "AgenticUserDeletedActivity",
+ "AgenticUserUndeletedActivity",
+ "AgenticUserWorkloadOnboardingUpdatedActivity",
+ "AgentLifecycleEventActivity",
+]
diff --git a/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/value.py b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/value.py
new file mode 100644
index 000000000..dbfd75155
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/activities/event/agent_lifecycle/value.py
@@ -0,0 +1,143 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from datetime import datetime
+from typing import Literal, Optional
+
+from ....models import CustomBaseModel
+
+
+class AgentLifecycleManager(CustomBaseModel):
+ """Manager profile carried by the ``AgenticUserIdentityCreated`` event."""
+
+ user_id: Optional[str] = None
+ """The Entra object ID of the manager."""
+
+ email: Optional[str] = None
+ """The manager's email address."""
+
+ display_name: Optional[str] = None
+ """The manager's display name."""
+
+
+class AgentLifecycleManagerRef(CustomBaseModel):
+ """Manager reference carried by the ``AgenticUserManagerUpdated`` event."""
+
+ manager_id: Optional[str] = None
+ """The Entra object ID of the manager."""
+
+
+class AgentLifecycleUpdatedProperty(CustomBaseModel):
+ """A single property change carried by the ``AgenticUserIdentityUpdated`` event."""
+
+ property_name: str
+ """The name of the property that changed (e.g. ``Mail``, ``Alias``, ``UserPrincipalName``)."""
+
+ property_value: Optional[str] = None
+ """The new value of the property."""
+
+
+class AgentLifecycleValueBase(CustomBaseModel):
+ """Fields shared by every agentLifecycle event payload."""
+
+ tenant_id: Optional[str] = None
+ """The tenant the agentic user belongs to."""
+
+ agentic_user_id: Optional[str] = None
+ """The Agent ID user-shaped identity object ID."""
+
+ agentic_app_instance_id: Optional[str] = None
+ """The concrete agent app instance ID."""
+
+ agent_identity_blueprint_id: Optional[str] = None
+ """The agent identity blueprint app ID."""
+
+ version: Optional[int] = None
+ """Monotonic version of the agentic user state, when provided by the service."""
+
+
+class AgenticUserIdentityCreatedValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserIdentityCreated`` event."""
+
+ event_type: Literal["agenticUserIdentityCreated"] = "agenticUserIdentityCreated"
+
+ manager: Optional[AgentLifecycleManager] = None
+ """The manager assigned to the agentic user at creation."""
+
+ expiration_date_time: Optional[datetime] = None
+ """When the agentic user identity expires."""
+
+
+class AgenticUserIdentityUpdatedValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserIdentityUpdated`` event."""
+
+ event_type: Literal["agenticUserIdentityUpdated"] = "agenticUserIdentityUpdated"
+
+ updated_property: AgentLifecycleUpdatedProperty
+ """The property that changed."""
+
+
+class AgenticUserManagerUpdatedValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserManagerUpdated`` event."""
+
+ event_type: Literal["agenticUserManagerUpdated"] = "agenticUserManagerUpdated"
+
+ manager: Optional[AgentLifecycleManagerRef] = None
+ """The new manager reference. Absent when the manager was removed."""
+
+
+class AgenticUserEnabledValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserEnabled`` event."""
+
+ event_type: Literal["agenticUserEnabled"] = "agenticUserEnabled"
+
+
+class AgenticUserDisabledValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserDisabled`` event."""
+
+ event_type: Literal["agenticUserDisabled"] = "agenticUserDisabled"
+
+
+class AgenticUserDeletedValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserDeleted`` event."""
+
+ event_type: Literal["agenticUserDeleted"] = "agenticUserDeleted"
+
+ deletion_reason: Optional[str] = None
+ """The reason the agentic user was deleted (e.g. ``UserSoftDelete``, ``UserHardDelete``)."""
+
+
+class AgenticUserUndeletedValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserUndeleted`` event."""
+
+ event_type: Literal["agenticUserUndeleted"] = "agenticUserUndeleted"
+
+
+class AgenticUserWorkloadOnboardingUpdatedValue(AgentLifecycleValueBase):
+ """Payload for the ``agenticUserWorkloadOnboardingUpdated`` event."""
+
+ event_type: Literal["agenticUserWorkloadOnboardingUpdated"] = "agenticUserWorkloadOnboardingUpdated"
+
+ workload_name: Optional[str] = None
+ """The workload being onboarded (e.g. ``Teams``)."""
+
+ workload_onboarding_state: Optional[str] = None
+ """The onboarding state for the workload (e.g. ``succeeded``)."""
+
+
+__all__ = [
+ "AgentLifecycleManager",
+ "AgentLifecycleManagerRef",
+ "AgentLifecycleUpdatedProperty",
+ "AgentLifecycleValueBase",
+ "AgenticUserIdentityCreatedValue",
+ "AgenticUserIdentityUpdatedValue",
+ "AgenticUserManagerUpdatedValue",
+ "AgenticUserEnabledValue",
+ "AgenticUserDisabledValue",
+ "AgenticUserDeletedValue",
+ "AgenticUserUndeletedValue",
+ "AgenticUserWorkloadOnboardingUpdatedValue",
+]
diff --git a/packages/api/src/microsoft_teams/api/auth/__init__.py b/packages/api/src/microsoft_teams/api/auth/__init__.py
index 10f62a86b..ff54c02eb 100644
--- a/packages/api/src/microsoft_teams/api/auth/__init__.py
+++ b/packages/api/src/microsoft_teams/api/auth/__init__.py
@@ -10,11 +10,13 @@
)
from .cloud_environment import from_name as config_from_cloud_name
from .credentials import (
+ BasicTokenProvider,
ClientCredentials,
Credentials,
FederatedIdentityCredentials,
ManagedIdentityCredentials,
TokenCredentials,
+ TokenProvider,
)
from .json_web_token import JsonWebToken, JsonWebTokenPayload
from .token import TokenProtocol
@@ -23,12 +25,14 @@
"CallerIds",
"CallerType",
"CloudEnvironment",
+ "BasicTokenProvider",
"ClientCredentials",
"config_from_cloud_name",
"Credentials",
"FederatedIdentityCredentials",
"ManagedIdentityCredentials",
"TokenCredentials",
+ "TokenProvider",
"TokenProtocol",
"JsonWebToken",
"JsonWebTokenPayload",
diff --git a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py
index 4821ca5b8..ef7ac2ed0 100644
--- a/packages/api/src/microsoft_teams/api/auth/cloud_environment.py
+++ b/packages/api/src/microsoft_teams/api/auth/cloud_environment.py
@@ -21,6 +21,8 @@ class CloudEnvironment:
"""The default multi-tenant login tenant (e.g. "botframework.com")."""
bot_scope: str
"""The Bot Framework OAuth scope (e.g. "https://api.botframework.com/.default")."""
+ agentic_bot_scope: str
+ """The Teams Bot API scope for Agent ID user-token calls."""
token_service_url: str
"""The Bot Framework token service base URL (e.g. "https://token.botframework.com")."""
openid_metadata_url: str
@@ -35,6 +37,7 @@ class CloudEnvironment:
login_endpoint="https://login.microsoftonline.com",
login_tenant="botframework.com",
bot_scope="https://api.botframework.com/.default",
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://token.botframework.com",
openid_metadata_url="https://login.botframework.com/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.com",
@@ -46,6 +49,8 @@ class CloudEnvironment:
login_endpoint="https://login.microsoftonline.us",
login_tenant="MicrosoftServices.onmicrosoft.us",
bot_scope="https://api.botframework.us/.default",
+ # TODO: confirm Agent ID Bot API scope for this cloud before enabling sovereign Agent ID scenarios.
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://tokengcch.botframework.azure.us",
openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.us",
@@ -57,6 +62,8 @@ class CloudEnvironment:
login_endpoint="https://login.microsoftonline.us",
login_tenant="MicrosoftServices.onmicrosoft.us",
bot_scope="https://api.botframework.us/.default",
+ # TODO: confirm Agent ID Bot API scope for this cloud before enabling sovereign Agent ID scenarios.
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://apiDoD.botframework.azure.us",
openid_metadata_url="https://login.botframework.azure.us/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.us",
@@ -68,6 +75,8 @@ class CloudEnvironment:
login_endpoint="https://login.partner.microsoftonline.cn",
login_tenant="microsoftservices.partner.onmschina.cn",
bot_scope="https://api.botframework.azure.cn/.default",
+ # TODO: confirm Agent ID Bot API scope for this cloud before enabling China cloud Agent ID scenarios.
+ agentic_bot_scope="https://botapi.skype.com/.default",
token_service_url="https://token.botframework.azure.cn",
openid_metadata_url="https://login.botframework.azure.cn/v1/.well-known/openidconfiguration",
token_issuer="https://api.botframework.azure.cn",
diff --git a/packages/api/src/microsoft_teams/api/auth/credentials.py b/packages/api/src/microsoft_teams/api/auth/credentials.py
index 471c7952c..5d3b4c3fc 100644
--- a/packages/api/src/microsoft_teams/api/auth/credentials.py
+++ b/packages/api/src/microsoft_teams/api/auth/credentials.py
@@ -3,9 +3,34 @@
Licensed under the MIT License.
"""
-from typing import Awaitable, Callable, Literal, Optional, Union
+from typing import Awaitable, Callable, Literal, Optional, Protocol, TypeAlias, Union, runtime_checkable
-from ..models import CustomBaseModel
+from ..models import AgenticIdentity, CustomBaseModel
+
+TokenScope: TypeAlias = Union[str, list[str]]
+TokenResult: TypeAlias = Union[str, Awaitable[str]]
+BasicTokenProvider: TypeAlias = Callable[[TokenScope, Optional[str]], TokenResult]
+_PositionalAgenticTokenProvider: TypeAlias = Callable[
+ [TokenScope, Optional[str], Optional[AgenticIdentity]], TokenResult
+]
+
+
+@runtime_checkable
+class _KeywordAgenticTokenProvider(Protocol):
+ def __call__(
+ self,
+ scope: TokenScope,
+ tenant_id: Optional[str],
+ *,
+ agentic_identity: Optional[AgenticIdentity] = None,
+ ) -> TokenResult: ...
+
+
+TokenProvider: TypeAlias = Union[
+ BasicTokenProvider,
+ _PositionalAgenticTokenProvider,
+ _KeywordAgenticTokenProvider,
+]
class ClientCredentials(CustomBaseModel):
@@ -36,8 +61,8 @@ class TokenCredentials(CustomBaseModel):
"""
The tenant ID.
"""
- # (scope: string | string[], tenantId?: string) => string | Promise
- token: Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]
+ # (scope: string | string[], tenantId?: string, agenticIdentity?: AgenticIdentity) => string | Promise
+ token: TokenProvider
"""
The token function.
"""
diff --git a/packages/api/src/microsoft_teams/api/clients/__init__.py b/packages/api/src/microsoft_teams/api/clients/__init__.py
index 683d9c1ed..1d86cf881 100644
--- a/packages/api/src/microsoft_teams/api/clients/__init__.py
+++ b/packages/api/src/microsoft_teams/api/clients/__init__.py
@@ -4,7 +4,8 @@
"""
from . import bot, conversation, meeting, reaction, team, user
-from .api_client import ApiClient
+from ._auth_provider import AuthProvider
+from .api_client import AGENTIC_IDENTITY_CLEAR, AgenticIdentityClear, AgenticIdentityScope, ApiClient
from .api_client_settings import ApiClientSettings, merge_api_client_settings
from .bot import * # noqa: F403
from .conversation import * # noqa: F403
@@ -17,6 +18,10 @@
__all__: list[str] = [
"ApiClient",
"ApiClientSettings",
+ "AuthProvider",
+ "AGENTIC_IDENTITY_CLEAR",
+ "AgenticIdentityClear",
+ "AgenticIdentityScope",
"merge_api_client_settings",
]
__all__.extend(bot.__all__)
diff --git a/packages/api/src/microsoft_teams/api/clients/_auth_provider.py b/packages/api/src/microsoft_teams/api/clients/_auth_provider.py
new file mode 100644
index 000000000..1b9866b79
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/clients/_auth_provider.py
@@ -0,0 +1,18 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from __future__ import annotations
+
+from typing import Awaitable, Protocol
+
+from microsoft_teams.common.http.client_token import StringLike
+
+from ..models.agentic_identity import AgenticIdentity
+
+
+class AuthProvider(Protocol):
+ def token(
+ self, *, scope: str | None = None, agentic_identity: AgenticIdentity | None = None
+ ) -> str | StringLike | None | Awaitable[str | StringLike | None]: ...
diff --git a/packages/api/src/microsoft_teams/api/clients/api_client.py b/packages/api/src/microsoft_teams/api/clients/api_client.py
index a97a43eb9..8130f10de 100644
--- a/packages/api/src/microsoft_teams/api/clients/api_client.py
+++ b/packages/api/src/microsoft_teams/api/clients/api_client.py
@@ -5,13 +5,16 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Optional, Union
+from typing import Literal, Optional, TypeAlias, Union
from microsoft_teams.common import Client as HttpClient
-from microsoft_teams.common import ClientOptions
+from microsoft_teams.common import ClientOptions, Token
from typing_extensions import deprecated
-from .api_client_settings import ApiClientSettings
+from ..auth.cloud_environment import PUBLIC, CloudEnvironment
+from ..models import AgenticIdentity
+from ._auth_provider import AuthProvider
+from .api_client_settings import ApiClientSettings, merge_api_client_settings
from .base_client import BaseClient
from .bot import BotClient # pyright: ignore[reportDeprecated]
from .conversation import ConversationClient
@@ -20,8 +23,9 @@
from .team import TeamClient
from .user import UserClient
-if TYPE_CHECKING:
- from ..auth.cloud_environment import CloudEnvironment
+AgenticIdentityClear: TypeAlias = Literal["clear"]
+AGENTIC_IDENTITY_CLEAR: AgenticIdentityClear = "clear"
+AgenticIdentityScope: TypeAlias = AgenticIdentity | None | AgenticIdentityClear
class ApiClient(BaseClient):
@@ -33,6 +37,9 @@ def __init__(
options: Optional[Union[HttpClient, ClientOptions]] = None,
api_client_settings: Optional[ApiClientSettings] = None,
cloud: Optional[CloudEnvironment] = None,
+ *,
+ auth_provider: Optional[AuthProvider] = None,
+ agentic_identity: Optional[AgenticIdentity] = None,
) -> None:
"""Initialize the unified Teams API client.
@@ -42,14 +49,22 @@ def __init__(
api_client_settings: Optional API client settings.
cloud: Optional cloud environment for sovereign cloud support.
"""
- super().__init__(options, api_client_settings)
+ self._cloud = cloud or PUBLIC
+ merged_settings = merge_api_client_settings(api_client_settings, self._cloud)
+ super().__init__(options, merged_settings)
self.service_url = service_url.rstrip("/")
+ if auth_provider is not None and self._http.token is not None:
+ raise ValueError("Cannot use both an auth provider and an HTTP client token.")
+
+ self._auth_provider = auth_provider
+ self._default_agentic_identity = agentic_identity
+ self._apply_auth_provider_token()
# Initialize all client types
self._bots = BotClient( # pyright: ignore[reportDeprecated]
- self._http, self._api_client_settings, cloud=cloud
+ self._http, self._api_client_settings, cloud=self._cloud
)
- self.users = UserClient(self._http, self._api_client_settings)
+ self.users = UserClient(self._http, self._api_client_settings, cloud=self._cloud)
self.conversations = ConversationClient(self.service_url, self._http, self._api_client_settings)
self.teams = TeamClient(self.service_url, self._http, self._api_client_settings)
self.meetings = MeetingClient(self.service_url, self._http, self._api_client_settings)
@@ -72,6 +87,70 @@ def reactions(self) -> ReactionClient:
self._reactions = ReactionClient(self.service_url, self._http, self._api_client_settings)
return self._reactions
+ def clone(
+ self,
+ *,
+ service_url: str | None = None,
+ agentic_identity: AgenticIdentityScope = None,
+ ) -> "ApiClient":
+ """Create a scoped API client.
+
+ Omitting agentic_identity, or passing None, preserves the existing scoped identity.
+ Pass AGENTIC_IDENTITY_CLEAR to clear it, or an AgenticIdentity to override it.
+ """
+ if agentic_identity is None:
+ resolved_agentic_identity = self._default_agentic_identity
+ elif agentic_identity == AGENTIC_IDENTITY_CLEAR:
+ resolved_agentic_identity = None
+ else:
+ resolved_agentic_identity = agentic_identity
+ http = self._http.clone(share_http=True)
+ if self._auth_provider is not None:
+ http.token = None
+
+ return ApiClient(
+ service_url or self.service_url,
+ http,
+ self._api_client_settings,
+ cloud=self._cloud,
+ auth_provider=self._auth_provider,
+ agentic_identity=resolved_agentic_identity,
+ )
+
+ def from_service_url(self, service_url: str) -> "ApiClient":
+ """Create a scoped API client for a different Teams service URL."""
+ return self.clone(service_url=service_url)
+
+ def from_agentic_identity(self, agentic_identity: AgenticIdentity) -> "ApiClient":
+ """Create a scoped API client for an agentic identity."""
+ return self.clone(agentic_identity=agentic_identity)
+
+ def for_agentic_identity(self, agentic_identity: AgenticIdentity) -> "ApiClient":
+ """Alias for from_agentic_identity."""
+ return self.from_agentic_identity(agentic_identity)
+
+ def _get_scoped_http(self, agentic_identity: AgenticIdentity | None) -> HttpClient:
+ if self._auth_provider is None:
+ return self._http.clone(share_http=True)
+
+ return self._http.clone(
+ ClientOptions(token=self._create_auth_provider_token(agentic_identity)),
+ share_http=True,
+ )
+
+ def _apply_auth_provider_token(self) -> None:
+ if self._auth_provider is None:
+ return
+
+ self._http = self._get_scoped_http(self._default_agentic_identity)
+
+ def _create_auth_provider_token(self, agentic_identity: AgenticIdentity | None) -> Token:
+ auth_provider = self._auth_provider
+ if auth_provider is None:
+ return None
+
+ return lambda: auth_provider.token(agentic_identity=agentic_identity)
+
@property
def http(self) -> HttpClient:
"""Get the HTTP client instance."""
@@ -80,11 +159,12 @@ def http(self) -> HttpClient:
@http.setter
def http(self, value: HttpClient) -> None:
"""Set the HTTP client instance and propagate to all sub-clients."""
- self._bots.http = value
- self.conversations.http = value
- self.users.http = value
- self.teams.http = value
- self.meetings.http = value
- if self._reactions is not None:
- self._reactions.http = value
self._http = value
+ self._apply_auth_provider_token()
+ self._bots.http = self._http
+ self.conversations.http = self._http
+ self.users.http = self._http
+ self.teams.http = self._http
+ self.meetings.http = self._http
+ if self._reactions is not None:
+ self._reactions.http = self._http
diff --git a/packages/api/src/microsoft_teams/api/clients/base_client.py b/packages/api/src/microsoft_teams/api/clients/base_client.py
index bc5ccd8f2..8b24b4979 100644
--- a/packages/api/src/microsoft_teams/api/clients/base_client.py
+++ b/packages/api/src/microsoft_teams/api/clients/base_client.py
@@ -3,9 +3,9 @@
Licensed under the MIT License.
"""
-from typing import Optional, Union
+from typing import Optional, Union, cast
-from microsoft_teams.common.http import Client, ClientOptions
+from microsoft_teams.common import Client, ClientOptions
from .api_client_settings import ApiClientSettings, merge_api_client_settings
@@ -21,7 +21,7 @@ def __init__(
"""Initialize the BaseClient.
Args:
- options: Optional Client or ClientOptions instance. If not provided, a default Client will be created.
+ options: Optional Client or ClientOptions instance. If not provided, a default Client is created.
api_client_settings: Optional API client settings.
"""
if options is None:
@@ -42,3 +42,9 @@ def http(self) -> Client:
def http(self, value: Client) -> None:
"""Set the HTTP client instance."""
self._http = value
+
+ def _get_service_url(self) -> str:
+ current_service_url = cast(str | None, getattr(self, "service_url", None))
+ if current_service_url is None:
+ raise ValueError("service_url is required")
+ return current_service_url.rstrip("/")
diff --git a/packages/api/src/microsoft_teams/api/clients/bot/__init__.py b/packages/api/src/microsoft_teams/api/clients/bot/__init__.py
index f743f2515..cd4609f8f 100644
--- a/packages/api/src/microsoft_teams/api/clients/bot/__init__.py
+++ b/packages/api/src/microsoft_teams/api/clients/bot/__init__.py
@@ -3,6 +3,7 @@
Licensed under the MIT License.
"""
+# Keep the deprecated BotClient re-export for backwards compatibility until it is removed.
from .client import BotClient # pyright: ignore[reportDeprecated]
from .params import GetBotSignInResourceParams, GetBotSignInUrlParams
from .sign_in_client import BotSignInClient
diff --git a/packages/api/src/microsoft_teams/api/clients/bot/client.py b/packages/api/src/microsoft_teams/api/clients/bot/client.py
index f8d3bf714..40ed7c5ad 100644
--- a/packages/api/src/microsoft_teams/api/clients/bot/client.py
+++ b/packages/api/src/microsoft_teams/api/clients/bot/client.py
@@ -5,19 +5,17 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Optional, Union
+from typing import Optional, Union
from microsoft_teams.common.http import Client, ClientOptions
from typing_extensions import deprecated
-from ..api_client_settings import ApiClientSettings
+from ...auth.cloud_environment import PUBLIC, CloudEnvironment
+from ..api_client_settings import ApiClientSettings, merge_api_client_settings
from ..base_client import BaseClient
from .sign_in_client import BotSignInClient
from .token_client import BotTokenClient
-if TYPE_CHECKING:
- from ...auth.cloud_environment import CloudEnvironment
-
@deprecated("The bot client is deprecated and will be removed in a future release.")
class BotClient(BaseClient):
@@ -36,9 +34,11 @@ def __init__(
api_client_settings: Optional API client settings.
cloud: Optional cloud environment for sovereign cloud support.
"""
- super().__init__(options, api_client_settings)
- self.token = BotTokenClient(self.http, self._api_client_settings, cloud=cloud)
- self.sign_in = BotSignInClient(self.http, self._api_client_settings)
+ self._cloud = cloud or PUBLIC
+ merged_settings = merge_api_client_settings(api_client_settings, self._cloud)
+ super().__init__(options, merged_settings)
+ self.token = BotTokenClient(self.http, self._api_client_settings, cloud=self._cloud)
+ self.sign_in = BotSignInClient(self.http, self._api_client_settings, cloud=self._cloud)
@property
def http(self) -> Client:
diff --git a/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py b/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py
index a6c65ae54..1251b24c8 100644
--- a/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py
+++ b/packages/api/src/microsoft_teams/api/clients/bot/sign_in_client.py
@@ -3,10 +3,13 @@
Licensed under the MIT License.
"""
+from __future__ import annotations
+
from typing import Optional, Union, cast
from microsoft_teams.common.http import Client, ClientOptions
+from ...auth.cloud_environment import PUBLIC, CloudEnvironment
from ...models import SignInUrlResponse
from ..api_client_settings import ApiClientSettings, merge_api_client_settings
from ..base_client import BaseClient
@@ -26,6 +29,7 @@ def __init__(
self,
options: Union[Client, ClientOptions, None] = None,
api_client_settings: Optional[ApiClientSettings] = None,
+ cloud: Optional[CloudEnvironment] = None,
) -> None:
"""Initialize the bot sign-in client.
@@ -33,8 +37,9 @@ def __init__(
options: Optional Client or ClientOptions instance.
api_client_settings: Optional API client settings.
"""
- super().__init__(options)
- self._api_client_settings = merge_api_client_settings(api_client_settings)
+ self._cloud = cloud or PUBLIC
+ merged_settings = merge_api_client_settings(api_client_settings, self._cloud)
+ super().__init__(options, merged_settings)
async def get_url(self, params: GetBotSignInUrlParams) -> str:
"""Get a sign-in URL.
diff --git a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py
index d2479e780..2865792ca 100644
--- a/packages/api/src/microsoft_teams/api/clients/bot/token_client.py
+++ b/packages/api/src/microsoft_teams/api/clients/bot/token_client.py
@@ -6,7 +6,7 @@
from __future__ import annotations
import inspect
-from typing import TYPE_CHECKING, Literal, Optional, Union
+from typing import TYPE_CHECKING, Any, Awaitable, Literal, Optional, Union, cast
from microsoft_teams.api.auth.credentials import ClientCredentials
from microsoft_teams.common.http import Client, ClientOptions
@@ -44,7 +44,11 @@ class GetBotTokenResponse(BaseModel):
class BotTokenClient(BaseClient):
- """Client for managing bot tokens."""
+ """Deprecated client for managing bot tokens.
+
+ Token minting for Teams apps now happens through the MSAL-backed TokenManager
+ in microsoft-teams-apps.
+ """
def __init__(
self,
@@ -59,9 +63,9 @@ def __init__(
api_client_settings: Optional API client settings.
cloud: Optional cloud environment for sovereign cloud support.
"""
- super().__init__(options)
self._cloud = cloud or PUBLIC
- self._api_client_settings = merge_api_client_settings(api_client_settings, self._cloud)
+ merged_settings = merge_api_client_settings(api_client_settings, self._cloud)
+ super().__init__(options, merged_settings)
async def get(self, credentials: Credentials) -> GetBotTokenResponse:
"""Get a bot token.
@@ -73,10 +77,7 @@ async def get(self, credentials: Credentials) -> GetBotTokenResponse:
The bot token response.
"""
if isinstance(credentials, TokenCredentials):
- token = credentials.token(
- self._cloud.bot_scope,
- credentials.tenant_id,
- )
+ token = self._call_token_provider(credentials, self._cloud.bot_scope)
if inspect.isawaitable(token):
token = await token
@@ -114,10 +115,7 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse:
The bot token response.
"""
if isinstance(credentials, TokenCredentials):
- token = credentials.token(
- self._cloud.graph_scope,
- credentials.tenant_id,
- )
+ token = self._call_token_provider(credentials, self._cloud.graph_scope)
if inspect.isawaitable(token):
token = await token
@@ -144,3 +142,30 @@ async def get_graph(self, credentials: Credentials) -> GetBotTokenResponse:
)
return GetBotTokenResponse.model_validate(res.json())
+
+ def _call_token_provider(self, credentials: TokenCredentials, scope: str) -> str | Awaitable[str]:
+ token_provider = cast(Any, credentials.token)
+ try:
+ parameters = list(inspect.signature(token_provider).parameters.values())
+ except (TypeError, ValueError):
+ return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id))
+
+ accepts_agentic_identity = any(
+ parameter.kind == inspect.Parameter.VAR_KEYWORD or parameter.name == "agentic_identity"
+ for parameter in parameters
+ )
+ if accepts_agentic_identity:
+ return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id, agentic_identity=None))
+
+ positional_parameters = [
+ parameter
+ for parameter in parameters
+ if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
+ ]
+ required_positional_parameters = [
+ parameter for parameter in positional_parameters if parameter.default is inspect.Parameter.empty
+ ]
+ if len(required_positional_parameters) >= 3:
+ return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id, None))
+
+ return cast(str | Awaitable[str], token_provider(scope, credentials.tenant_id))
diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py
index ab25d52b7..24fa0a14f 100644
--- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py
+++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py
@@ -38,7 +38,11 @@ def __init__(
super().__init__(http_client, api_client_settings)
self.service_url = service_url.rstrip("/")
- async def create(self, conversation_id: str, activity: ActivityParams) -> SentActivity:
+ async def create(
+ self,
+ conversation_id: str,
+ activity: ActivityParams,
+ ) -> SentActivity:
"""
Create a new activity in a conversation.
@@ -52,7 +56,7 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc
# TODO: Will be deprecated alongside accessor in ConversationClient
response = await self.http.post(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities",
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities",
json=activity.model_dump(by_alias=True, exclude_none=True),
)
@@ -62,7 +66,12 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc
id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID)
return SentActivity(id=id, activity_params=activity)
- async def update(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
+ async def update(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ activity: ActivityParams,
+ ) -> SentActivity:
"""
Update an existing activity in a conversation.
@@ -76,13 +85,18 @@ async def update(self, conversation_id: str, activity_id: str, activity: Activit
"""
# TODO: Will be deprecated alongside accessor in ConversationClient
response = await self.http.put(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}",
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities/{activity_id}",
json=activity.model_dump(by_alias=True, exclude_none=True),
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)
- async def reply(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
+ async def reply(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ activity: ActivityParams,
+ ) -> SentActivity:
"""
Reply to an activity in a conversation.
@@ -98,13 +112,17 @@ async def reply(self, conversation_id: str, activity_id: str, activity: Activity
activity_json = activity.model_dump(by_alias=True, exclude_none=True)
activity_json["replyToId"] = activity_id
response = await self.http.post(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}",
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities/{activity_id}",
json=activity_json,
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)
- async def delete(self, conversation_id: str, activity_id: str) -> None:
+ async def delete(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ ) -> None:
"""
Delete an activity from a conversation.
@@ -112,10 +130,15 @@ async def delete(self, conversation_id: str, activity_id: str) -> None:
conversation_id: The ID of the conversation
activity_id: The ID of the activity to delete
"""
- # TODO: Will be deprecated alongside accessor in ConversationClient
- await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}")
+ await self.http.delete(
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities/{activity_id}",
+ )
- async def get_members(self, conversation_id: str, activity_id: str) -> List[TeamsChannelAccount]:
+ async def get_members(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ ) -> List[TeamsChannelAccount]:
"""
Get the members associated with an activity.
@@ -128,12 +151,16 @@ async def get_members(self, conversation_id: str, activity_id: str) -> List[Team
"""
# TODO: Will be deprecated alongside accessor in ConversationClient
response = await self.http.get(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/members"
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities/{activity_id}/members",
)
return [TeamsChannelAccount.model_validate(member) for member in response.json()]
@experimental("ExperimentalTeamsTargeted")
- async def create_targeted(self, conversation_id: str, activity: ActivityParams) -> SentActivity:
+ async def create_targeted(
+ self,
+ conversation_id: str,
+ activity: ActivityParams,
+ ) -> SentActivity:
"""
Create a new targeted activity in a conversation.
@@ -152,14 +179,19 @@ async def create_targeted(self, conversation_id: str, activity: ActivityParams)
"""
# TODO: Will be deprecated alongside accessor in ConversationClient
response = await self.http.post(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true",
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities?isTargetedActivity=true",
json=activity.model_dump(by_alias=True, exclude_none=True),
)
id = response.json().get("id", _PLACEHOLDER_ACTIVITY_ID)
return SentActivity(id=id, activity_params=activity)
@experimental("ExperimentalTeamsTargeted")
- async def update_targeted(self, conversation_id: str, activity_id: str, activity: ActivityParams) -> SentActivity:
+ async def update_targeted(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ activity: ActivityParams,
+ ) -> SentActivity:
"""
Update an existing targeted activity in a conversation.
@@ -177,14 +209,18 @@ async def update_targeted(self, conversation_id: str, activity_id: str, activity
"""
# TODO: Will be deprecated alongside accessor in ConversationClient
response = await self.http.put(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true",
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true",
json=activity.model_dump(by_alias=True, exclude_none=True),
)
id = response.json()["id"]
return SentActivity(id=id, activity_params=activity)
@experimental("ExperimentalTeamsTargeted")
- async def delete_targeted(self, conversation_id: str, activity_id: str) -> None:
+ async def delete_targeted(
+ self,
+ conversation_id: str,
+ activity_id: str,
+ ) -> None:
"""
Delete a targeted activity from a conversation.
@@ -198,5 +234,5 @@ async def delete_targeted(self, conversation_id: str, activity_id: str) -> None:
"""
# TODO: Will be deprecated alongside accessor in ConversationClient
await self.http.delete(
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true"
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/activities/{activity_id}?isTargetedActivity=true"
)
diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/client.py b/packages/api/src/microsoft_teams/api/clients/conversation/client.py
index 72f7dd676..a8fcdba60 100644
--- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py
+++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py
@@ -63,8 +63,16 @@ class MemberOperations(ConversationOperations):
async def get_all(self):
return await self._client.members_client.get(self._conversation_id)
- async def get_paged(self, page_size: Optional[int] = None, continuation_token: Optional[str] = None):
- return await self._client.members_client.get_paged(self._conversation_id, page_size, continuation_token)
+ async def get_paged(
+ self,
+ page_size: Optional[int] = None,
+ continuation_token: Optional[str] = None,
+ ):
+ return await self._client.members_client.get_paged(
+ self._conversation_id,
+ page_size,
+ continuation_token,
+ )
async def get(self, member_id: str):
return await self._client.members_client.get_by_id(self._conversation_id, member_id)
@@ -89,8 +97,16 @@ def __init__(
super().__init__(options, api_client_settings)
self.service_url = service_url.rstrip("/")
- self._activities_client = ConversationActivityClient(self.service_url, self.http, self._api_client_settings)
- self._members_client = ConversationMemberClient(self.service_url, self.http, self._api_client_settings)
+ self._activities_client = ConversationActivityClient(
+ self.service_url,
+ self.http,
+ self._api_client_settings,
+ )
+ self._members_client = ConversationMemberClient(
+ self.service_url,
+ self.http,
+ self._api_client_settings,
+ )
self._reactions_client = ReactionClient(self.service_url, self.http, self._api_client_settings)
@property
@@ -205,7 +221,10 @@ async def delete_reaction(self, conversation_id: str, activity_id: str, reaction
"""Delete a reaction from an activity in a conversation."""
return await self._reactions_client.delete(conversation_id, activity_id, reaction_type)
- async def create(self, params: CreateConversationParams) -> ConversationResource:
+ async def create(
+ self,
+ params: CreateConversationParams,
+ ) -> ConversationResource:
"""Create a new conversation.
Args:
@@ -215,7 +234,7 @@ async def create(self, params: CreateConversationParams) -> ConversationResource
The created conversation resource.
"""
response = await self.http.post(
- f"{self.service_url}/v3/conversations",
+ f"{self._get_service_url()}/v3/conversations",
json=params.model_dump(by_alias=True, exclude_none=True),
)
return ConversationResource.model_validate(response.json())
diff --git a/packages/api/src/microsoft_teams/api/clients/conversation/member.py b/packages/api/src/microsoft_teams/api/clients/conversation/member.py
index 89c0d301b..9b82bbdfa 100644
--- a/packages/api/src/microsoft_teams/api/clients/conversation/member.py
+++ b/packages/api/src/microsoft_teams/api/clients/conversation/member.py
@@ -3,6 +3,8 @@
Licensed under the MIT License.
"""
+from __future__ import annotations
+
from typing import List, Optional
from microsoft_teams.common.http import Client
@@ -35,7 +37,10 @@ def __init__(
super().__init__(http_client, api_client_settings)
self.service_url = service_url.rstrip("/")
- async def get(self, conversation_id: str) -> List[TeamsChannelAccount]:
+ async def get(
+ self,
+ conversation_id: str,
+ ) -> List[TeamsChannelAccount]:
"""
Get all members in a conversation.
@@ -45,8 +50,9 @@ async def get(self, conversation_id: str) -> List[TeamsChannelAccount]:
Returns:
List of TeamsChannelAccount objects representing the conversation members
"""
- # TODO: Will be deprecated alongside accessor in ConversationClient
- response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members")
+ response = await self.http.get(
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/members",
+ )
return [TeamsChannelAccount.model_validate(member) for member in response.json()]
async def get_paged(
@@ -67,12 +73,23 @@ async def get_paged(
PagedMembersResult containing the members and an optional continuation token
for fetching subsequent pages.
"""
- # TODO: Will be deprecated alongside accessor in ConversationClient
- url = f"{self.service_url}/v3/conversations/{conversation_id}/pagedMembers"
- response = await self.http.get(url, params={"pageSize": page_size, "continuationToken": continuation_token})
+ url = f"{self._get_service_url()}/v3/conversations/{conversation_id}/pagedMembers"
+ params: dict[str, int | str] = {}
+ if page_size is not None:
+ params["pageSize"] = page_size
+ if continuation_token is not None:
+ params["continuationToken"] = continuation_token
+ response = await self.http.get(
+ url,
+ params=params,
+ )
return PagedMembersResult.model_validate(response.json())
- async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelAccount:
+ async def get_by_id(
+ self,
+ conversation_id: str,
+ member_id: str,
+ ) -> TeamsChannelAccount:
"""
Get a specific member in a conversation.
@@ -83,6 +100,7 @@ async def get_by_id(self, conversation_id: str, member_id: str) -> TeamsChannelA
Returns:
TeamsChannelAccount object representing the conversation member
"""
- # TODO: Will be deprecated alongside accessor in ConversationClient
- response = await self.http.get(f"{self.service_url}/v3/conversations/{conversation_id}/members/{member_id}")
+ response = await self.http.get(
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}/members/{member_id}",
+ )
return TeamsChannelAccount.model_validate(response.json())
diff --git a/packages/api/src/microsoft_teams/api/clients/meeting/client.py b/packages/api/src/microsoft_teams/api/clients/meeting/client.py
index cea7277b9..f21f3c7cd 100644
--- a/packages/api/src/microsoft_teams/api/clients/meeting/client.py
+++ b/packages/api/src/microsoft_teams/api/clients/meeting/client.py
@@ -3,6 +3,8 @@
Licensed under the MIT License.
"""
+from __future__ import annotations
+
from typing import Optional, Union
from microsoft_teams.common.http import Client, ClientOptions
@@ -43,10 +45,17 @@ async def get_by_id(self, id: str) -> MeetingInfo:
Returns:
The meeting information.
"""
- response = await self.http.get(f"{self.service_url}/v1/meetings/{id}")
+ response = await self.http.get(
+ f"{self._get_service_url()}/v1/meetings/{id}",
+ )
return MeetingInfo.model_validate(response.json())
- async def get_participant(self, meeting_id: str, id: str, tenant_id: str) -> MeetingParticipant:
+ async def get_participant(
+ self,
+ meeting_id: str,
+ id: str,
+ tenant_id: str,
+ ) -> MeetingParticipant:
"""
Retrieves information about a specific participant in a meeting.
@@ -58,12 +67,14 @@ async def get_participant(self, meeting_id: str, id: str, tenant_id: str) -> Mee
Returns:
MeetingParticipant: The meeting participant information.
"""
- url = f"{self.service_url}/v1/meetings/{meeting_id}/participants/{id}?tenantId={tenant_id}"
+ url = f"{self._get_service_url()}/v1/meetings/{meeting_id}/participants/{id}?tenantId={tenant_id}"
response = await self.http.get(url)
return MeetingParticipant.model_validate(response.json())
async def send_notification(
- self, meeting_id: str, params: MeetingNotificationParams
+ self,
+ meeting_id: str,
+ params: MeetingNotificationParams,
) -> Optional[MeetingNotificationResponse]:
"""
Send a targeted meeting notification to participants.
@@ -80,7 +91,7 @@ async def send_notification(
with per-recipient failure details on partial success.
"""
response = await self.http.post(
- f"{self.service_url}/v1/meetings/{meeting_id}/notification",
+ f"{self._get_service_url()}/v1/meetings/{meeting_id}/notification",
json=params.model_dump(by_alias=True, exclude_none=True),
)
if not response.text:
diff --git a/packages/api/src/microsoft_teams/api/clients/reaction/client.py b/packages/api/src/microsoft_teams/api/clients/reaction/client.py
index 3bd59ae14..642ca8027 100644
--- a/packages/api/src/microsoft_teams/api/clients/reaction/client.py
+++ b/packages/api/src/microsoft_teams/api/clients/reaction/client.py
@@ -50,7 +50,8 @@ async def add(
"""
# TODO: Will be deprecated alongside accessor in ConversationClient
url = (
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}"
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}"
+ f"/activities/{activity_id}/reactions/{reaction_type}"
)
await self.http.put(url)
@@ -70,6 +71,7 @@ async def delete(
"""
# TODO: Will be deprecated alongside accessor in ConversationClient
url = (
- f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}/reactions/{reaction_type}"
+ f"{self._get_service_url()}/v3/conversations/{conversation_id}"
+ f"/activities/{activity_id}/reactions/{reaction_type}"
)
await self.http.delete(url)
diff --git a/packages/api/src/microsoft_teams/api/clients/team/client.py b/packages/api/src/microsoft_teams/api/clients/team/client.py
index 7ffca2e92..75eabc606 100644
--- a/packages/api/src/microsoft_teams/api/clients/team/client.py
+++ b/packages/api/src/microsoft_teams/api/clients/team/client.py
@@ -3,6 +3,8 @@
Licensed under the MIT License.
"""
+from __future__ import annotations
+
from typing import List, Optional, Union
from microsoft_teams.common.http import Client, ClientOptions
@@ -43,7 +45,9 @@ async def get_by_id(self, id: str) -> TeamDetails:
Returns:
The team details.
"""
- response = await self.http.get(f"{self.service_url}/v3/teams/{id}")
+ response = await self.http.get(
+ f"{self._get_service_url()}/v3/teams/{id}",
+ )
return TeamDetails.model_validate(response.json())
async def get_conversations(self, id: str) -> List[ChannelInfo]:
@@ -56,5 +60,7 @@ async def get_conversations(self, id: str) -> List[ChannelInfo]:
Returns:
List of channel information.
"""
- response = await self.http.get(f"{self.service_url}/v3/teams/{id}/conversations")
+ response = await self.http.get(
+ f"{self._get_service_url()}/v3/teams/{id}/conversations",
+ )
return GetTeamConversationsResponse.model_validate(response.json()).conversations
diff --git a/packages/api/src/microsoft_teams/api/clients/user/client.py b/packages/api/src/microsoft_teams/api/clients/user/client.py
index 770779eff..97524d36d 100644
--- a/packages/api/src/microsoft_teams/api/clients/user/client.py
+++ b/packages/api/src/microsoft_teams/api/clients/user/client.py
@@ -3,13 +3,16 @@
Licensed under the MIT License.
"""
+from __future__ import annotations
+
from typing import Dict, List, Optional, Union
from microsoft_teams.common.http import Client, ClientOptions
from typing_extensions import deprecated
+from ...auth.cloud_environment import PUBLIC, CloudEnvironment
from ...models import TokenResponse, TokenStatus
-from ..api_client_settings import ApiClientSettings
+from ..api_client_settings import ApiClientSettings, merge_api_client_settings
from ..base_client import BaseClient
from .params import (
ExchangeUserTokenParams,
@@ -28,6 +31,8 @@ def __init__(
self,
options: Optional[Union[Client, ClientOptions]] = None,
api_client_settings: Optional[ApiClientSettings] = None,
+ *,
+ cloud: Optional[CloudEnvironment] = None,
) -> None:
"""
Initialize the UserClient.
@@ -36,8 +41,10 @@ def __init__(
options: Optional Client or ClientOptions instance. If not provided, a default Client will be created.
api_client_settings: Optional API client settings.
"""
- super().__init__(options, api_client_settings)
- self._token = UserTokenClient(self.http, self._api_client_settings)
+ self._cloud = cloud or PUBLIC
+ merged_settings = merge_api_client_settings(api_client_settings, self._cloud)
+ super().__init__(options, merged_settings)
+ self._token = UserTokenClient(self.http, self._api_client_settings, cloud=self._cloud)
@property
def http(self) -> Client:
diff --git a/packages/api/src/microsoft_teams/api/clients/user/token_client.py b/packages/api/src/microsoft_teams/api/clients/user/token_client.py
index acab73ba3..473496c45 100644
--- a/packages/api/src/microsoft_teams/api/clients/user/token_client.py
+++ b/packages/api/src/microsoft_teams/api/clients/user/token_client.py
@@ -3,10 +3,13 @@
Licensed under the MIT License.
"""
+from __future__ import annotations
+
from typing import Dict, List, Optional, Union
from microsoft_teams.common.http import Client, ClientOptions
+from ...auth.cloud_environment import PUBLIC, CloudEnvironment
from ...models import TokenResponse, TokenStatus
from ..api_client_settings import ApiClientSettings, merge_api_client_settings
from ..base_client import BaseClient
@@ -35,6 +38,8 @@ def __init__(
self,
options: Optional[Union[Client, ClientOptions]] = None,
api_client_settings: Optional[ApiClientSettings] = None,
+ *,
+ cloud: Optional[CloudEnvironment] = None,
) -> None:
"""
Initialize the UserTokenClient.
@@ -43,8 +48,9 @@ def __init__(
options: Optional Client or ClientOptions instance. If not provided, a default Client will be created.
api_client_settings: Optional API client settings.
"""
- super().__init__(options)
- self._api_client_settings = merge_api_client_settings(api_client_settings)
+ self._cloud = cloud or PUBLIC
+ merged_settings = merge_api_client_settings(api_client_settings, self._cloud)
+ super().__init__(options, merged_settings)
async def get(self, params: GetUserTokenParams) -> TokenResponse:
"""
diff --git a/packages/api/src/microsoft_teams/api/models/__init__.py b/packages/api/src/microsoft_teams/api/models/__init__.py
index df7e229fd..b00644124 100644
--- a/packages/api/src/microsoft_teams/api/models/__init__.py
+++ b/packages/api/src/microsoft_teams/api/models/__init__.py
@@ -27,6 +27,7 @@
from .activity import Activity as ActivityBase
from .activity import ActivityInput as ActivityInputBase
from .adaptive_card import * # noqa: F403
+from .agentic_identity import AgenticIdentity
from .app_based_link_query import AppBasedLinkQuery
from .attachment import * # noqa: F403
from .cache_info import CacheInfo
@@ -75,6 +76,7 @@
"Action",
"ActivityBase",
"ActivityInputBase",
+ "AgenticIdentity",
"AppBasedLinkQuery",
"CacheInfo",
"ChannelID",
diff --git a/packages/api/src/microsoft_teams/api/models/account.py b/packages/api/src/microsoft_teams/api/models/account.py
index 9c58c8f2b..33fad58e0 100644
--- a/packages/api/src/microsoft_teams/api/models/account.py
+++ b/packages/api/src/microsoft_teams/api/models/account.py
@@ -7,9 +7,11 @@
from pydantic import AliasChoices, Field
+from .agentic_identity import AgenticIdentity
from .custom_base_model import CustomBaseModel
AccountType = Literal["person", "tag", "channel", "team", "bot"]
+AccountRole = Literal["agenticUser"]
class Account(CustomBaseModel):
@@ -44,6 +46,30 @@ class Account(CustomBaseModel):
"""
The name of the account.
"""
+ role: Optional[AccountRole | str] = None
+ """The role of the account in the activity."""
+ agentic_user_id: Optional[str] = None
+ """The Agent ID user-shaped identity object ID."""
+ agentic_app_id: Optional[str] = None
+ """The Agent ID app/client ID for the concrete agent identity."""
+ agentic_app_blueprint_id: Optional[str] = None
+ """The Agent ID blueprint app/client ID."""
+ callback_uri: Optional[str] = None
+ """The callback URI associated with the agent identity."""
+ tenant_id: Optional[str] = None
+ """The tenant ID associated with the account."""
+
+ @property
+ def agentic_identity(self) -> Optional[AgenticIdentity]:
+ if self.agentic_app_id is None or self.agentic_user_id is None:
+ return None
+
+ return AgenticIdentity(
+ agentic_app_id=self.agentic_app_id,
+ agentic_user_id=self.agentic_user_id,
+ tenant_id=self.tenant_id,
+ agentic_app_blueprint_id=self.agentic_app_blueprint_id,
+ )
class TeamsChannelAccount(CustomBaseModel):
diff --git a/packages/api/src/microsoft_teams/api/models/agentic_identity.py b/packages/api/src/microsoft_teams/api/models/agentic_identity.py
new file mode 100644
index 000000000..dcf893614
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/models/agentic_identity.py
@@ -0,0 +1,19 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class AgenticIdentity:
+ """Identifies an Agent ID user-shaped identity and its backing agent app."""
+
+ agentic_app_id: str
+ agentic_user_id: str
+ tenant_id: str | None = None
+ agentic_app_blueprint_id: str | None = None
+
+
+__all__ = ["AgenticIdentity"]
diff --git a/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py
index f4b242b9b..95b5e07c2 100644
--- a/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py
+++ b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py
@@ -10,8 +10,7 @@
class QuotedReplyData(CustomBaseModel):
- """Data for a quoted reply entity
- """
+ """Data for a quoted reply entity"""
message_id: str
"ID of the message being quoted"
@@ -36,8 +35,7 @@ class QuotedReplyData(CustomBaseModel):
class QuotedReplyEntity(EntityBase):
- """Entity containing quoted reply information
- """
+ """Entity containing quoted reply information"""
type: Literal["quotedReply"] = "quotedReply"
"Type identifier for quoted reply"
diff --git a/packages/api/tests/conftest.py b/packages/api/tests/conftest.py
index 2fa97f016..775b9c45e 100644
--- a/packages/api/tests/conftest.py
+++ b/packages/api/tests/conftest.py
@@ -240,6 +240,16 @@ def mock_http_client(mock_transport):
return client
+def _preserve_test_transport_on_clone(client: Client, transport: httpx.MockTransport) -> Client:
+ def clone(overrides: Optional[ClientOptions] = None, *, share_http: bool = False) -> Client:
+ cloned = Client.clone(client, overrides, share_http=share_http)
+ cloned.http._transport = transport
+ return _preserve_test_transport_on_clone(cloned, transport)
+
+ client.clone = clone # type: ignore[method-assign]
+ return client
+
+
@pytest.fixture
def request_capture():
"""Fixture to capture HTTP request details for testing.
@@ -358,6 +368,7 @@ def clear(self):
transport = httpx.MockTransport(capture.handler)
client = Client(ClientOptions(base_url="https://mock.api.com"))
client.http._transport = transport
+ _preserve_test_transport_on_clone(client, transport)
client._capture = capture # type: ignore[attr-defined] # Attach for test access
return client
diff --git a/packages/api/tests/unit/test_agent_lifecycle_event.py b/packages/api/tests/unit/test_agent_lifecycle_event.py
new file mode 100644
index 000000000..9961a5976
--- /dev/null
+++ b/packages/api/tests/unit/test_agent_lifecycle_event.py
@@ -0,0 +1,171 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+# pyright: basic
+
+from typing import Any, Dict
+
+import pytest
+from microsoft_teams.api.activities import ActivityTypeAdapter
+from microsoft_teams.api.activities.event.agent_lifecycle import (
+ AgenticUserDeletedActivity,
+ AgenticUserDisabledActivity,
+ AgenticUserEnabledActivity,
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityUpdatedActivity,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserUndeletedActivity,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
+)
+
+# Static sample IDs for unit-test payloads; these tests do not call live services.
+TENANT_ID = "00000000-0000-0000-0000-000000000001"
+AGENTIC_USER_ID = "00000000-0000-0000-0000-000000000002"
+APP_ID = "00000000-0000-0000-0000-000000000003"
+AGENTIC_APP_INSTANCE_ID = "00000000-0000-0000-0000-000000000004"
+BLUEPRINT_ID = "00000000-0000-0000-0000-000000000005"
+
+
+def _envelope(value: Dict[str, Any], value_type: str) -> Dict[str, Any]:
+ return {
+ "recipient": {
+ "agenticUserId": AGENTIC_USER_ID,
+ "agenticAppId": APP_ID,
+ "agenticAppBlueprintId": BLUEPRINT_ID,
+ "callbackUri": "https://example.test/api/messages",
+ "tenantId": TENANT_ID,
+ "role": "agenticUser",
+ "id": AGENTIC_USER_ID,
+ },
+ "type": "event",
+ "id": "activity-id",
+ "timestamp": "2026-06-29T00:00:00Z",
+ "serviceUrl": "https://smba.trafficmanager.net/amer/tenant/",
+ "channelId": "agents",
+ "from": {"id": "system", "name": "System", "tenantId": TENANT_ID},
+ "conversation": {"tenantId": TENANT_ID, "id": "conversation-id", "topic": None},
+ "channelData": {"tenant": {"id": TENANT_ID}, "productContext": None},
+ "valueType": value_type,
+ "value": value,
+ "name": "agentLifecycle",
+ }
+
+
+def _common(event_type: str) -> Dict[str, Any]:
+ return {
+ "tenantId": TENANT_ID,
+ "agenticUserId": AGENTIC_USER_ID,
+ "agenticAppInstanceId": AGENTIC_APP_INSTANCE_ID,
+ "agentIdentityBlueprintId": BLUEPRINT_ID,
+ "eventType": event_type,
+ }
+
+
+@pytest.mark.unit
+class TestAgentLifecycleEventParsing:
+ def test_identity_created(self) -> None:
+ value = {
+ "expirationDateTime": "0001-01-01T00:00:00+00:00",
+ "manager": {
+ "displayName": None,
+ "userId": "3c22b565-74f3-48b0-aa18-1dc03b8ec270",
+ "email": "manager@example.test",
+ },
+ **_common("agenticUserIdentityCreated"),
+ }
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserIdentityCreated"))
+
+ assert isinstance(activity, AgenticUserIdentityCreatedActivity)
+ assert activity.name == "agentLifecycle"
+ assert activity.value_type == "AgenticUserIdentityCreated"
+ assert activity.value.agentic_user_id == AGENTIC_USER_ID
+ assert activity.value.manager is not None
+ assert activity.value.manager.user_id == "3c22b565-74f3-48b0-aa18-1dc03b8ec270"
+ assert activity.value.manager.email == "manager@example.test"
+
+ @pytest.mark.parametrize(
+ "property_name,property_value",
+ [
+ ("Mail", "newinstance4@teamssdk.onmicrosoft.com"),
+ ("Alias", "newinstance4"),
+ ("UserPrincipalName", "newinstance4@teamssdk.onmicrosoft.com"),
+ ],
+ )
+ def test_identity_updated(self, property_name: str, property_value: str) -> None:
+ value = {
+ "updatedProperty": {"propertyName": property_name, "propertyValue": property_value},
+ **_common("agenticUserIdentityUpdated"),
+ "version": 4,
+ }
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserIdentityUpdated"))
+
+ assert isinstance(activity, AgenticUserIdentityUpdatedActivity)
+ assert activity.value.updated_property.property_name == property_name
+ assert activity.value.updated_property.property_value == property_value
+ assert activity.value.version == 4
+
+ def test_manager_updated(self) -> None:
+ value = {
+ "manager": {"managerId": "3c22b565-74f3-48b0-aa18-1dc03b8ec270"},
+ **_common("agenticUserManagerUpdated"),
+ "version": 6,
+ }
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserManagerUpdated"))
+
+ assert isinstance(activity, AgenticUserManagerUpdatedActivity)
+ assert activity.value.manager is not None
+ assert activity.value.manager.manager_id == "3c22b565-74f3-48b0-aa18-1dc03b8ec270"
+ assert activity.value.version == 6
+
+ def test_enabled(self) -> None:
+ value = {**_common("agenticUserEnabled"), "version": 6}
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserEnabled"))
+
+ assert isinstance(activity, AgenticUserEnabledActivity)
+ assert activity.value.version == 6
+
+ def test_disabled(self) -> None:
+ value = {**_common("agenticUserDisabled"), "version": 7}
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserDisabled"))
+
+ assert isinstance(activity, AgenticUserDisabledActivity)
+
+ def test_deleted(self) -> None:
+ value = {**_common("agenticUserDeleted"), "deletionReason": "UserSoftDelete", "version": 8}
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserDeleted"))
+
+ assert isinstance(activity, AgenticUserDeletedActivity)
+ assert activity.value.deletion_reason == "UserSoftDelete"
+
+ def test_undeleted(self) -> None:
+ value = {**_common("agenticUserUndeleted"), "version": 9}
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserUndeleted"))
+
+ assert isinstance(activity, AgenticUserUndeletedActivity)
+
+ def test_workload_onboarding_updated(self) -> None:
+ value = {
+ "workloadName": "Teams",
+ "workloadOnboardingState": "succeeded",
+ **_common("agenticUserWorkloadOnboardingUpdated"),
+ }
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserWorkloadOnboardingUpdated"))
+
+ assert isinstance(activity, AgenticUserWorkloadOnboardingUpdatedActivity)
+ assert activity.value.workload_name == "Teams"
+ assert activity.value.workload_onboarding_state == "succeeded"
+
+ def test_round_trip_serialization_uses_camel_case(self) -> None:
+ value = {
+ "manager": {"managerId": "3c22b565-74f3-48b0-aa18-1dc03b8ec270"},
+ **_common("agenticUserManagerUpdated"),
+ "version": 6,
+ }
+ activity = ActivityTypeAdapter.validate_python(_envelope(value, "AgenticUserManagerUpdated"))
+ dumped = activity.model_dump(by_alias=True, exclude_none=True)
+
+ assert dumped["name"] == "agentLifecycle"
+ assert dumped["valueType"] == "AgenticUserManagerUpdated"
+ assert dumped["value"]["agenticAppInstanceId"] == AGENTIC_APP_INSTANCE_ID
+ assert dumped["value"]["manager"]["managerId"] == "3c22b565-74f3-48b0-aa18-1dc03b8ec270"
diff --git a/packages/api/tests/unit/test_api_client.py b/packages/api/tests/unit/test_api_client.py
index 2ad32eb24..13860f747 100644
--- a/packages/api/tests/unit/test_api_client.py
+++ b/packages/api/tests/unit/test_api_client.py
@@ -7,7 +7,8 @@
from unittest.mock import AsyncMock, patch
import pytest
-from microsoft_teams.api.clients import ApiClient, ReactionClient
+from microsoft_teams.api.clients import AGENTIC_IDENTITY_CLEAR, ApiClient, ReactionClient
+from microsoft_teams.api.models import AgenticIdentity
from microsoft_teams.common.http import Client, ClientOptions
@@ -25,6 +26,28 @@ def test_reactions_first_access_creates_reaction_client(self, mock_http_client):
assert reactions is not None
assert isinstance(reactions, ReactionClient)
+ def test_reactions_inherits_agentic_auth_defaults(self, mock_http_client):
+ """Test reactions inherits agentic auth defaults from ApiClient."""
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ return "agentic-token"
+
+ client = ApiClient(
+ "https://mock.service.url",
+ mock_http_client,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=identity,
+ )
+
+ reactions = client.reactions
+
+ assert not hasattr(reactions, "_auth_provider")
+ assert not hasattr(reactions, "_agentic_identity")
+ assert client.http.token is not None
+ assert reactions.http is client.http
+
def test_reactions_second_access_returns_cached_client(self, mock_http_client):
"""Test that the reactions property returns the same instance on subsequent accesses."""
client = ApiClient("https://mock.service.url", mock_http_client)
@@ -98,3 +121,244 @@ async def test_deprecated_reactions_add_still_routes(self, mock_http_client):
mock_put.assert_called_once_with(
"https://mock.service.url/v3/conversations/conv-1/activities/act-1/reactions/like"
)
+
+
+@pytest.mark.unit
+class TestApiClientScoping:
+ def test_clone_preserves_defaults_when_omitted(self, mock_http_client):
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient("https://mock.service.url", mock_http_client, agentic_identity=identity)
+
+ clone = client.clone()
+
+ assert clone.service_url == "https://mock.service.url"
+ assert clone._default_agentic_identity is identity
+ assert clone._api_client_settings is client._api_client_settings
+ assert clone._cloud is client._cloud
+
+ def test_clone_reuses_underlying_http_client_when_agentic_identity_is_unchanged(self, mock_http_client):
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ return "agentic-token"
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://mock.service.url",
+ mock_http_client,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=identity,
+ )
+
+ clone = client.from_service_url("https://override.service.url")
+
+ assert clone.http is not client.http
+ assert clone.http.http is client.http.http
+ assert clone._default_agentic_identity is identity
+
+ def test_clone_replaces_http_client_when_agentic_identity_changes(self, mock_http_client):
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ return "agentic-token"
+
+ default_identity = AgenticIdentity("default-app-id", "default-user-id", tenant_id="default-tenant-id")
+ override_identity = AgenticIdentity("override-app-id", "override-user-id", tenant_id="override-tenant-id")
+ client = ApiClient(
+ "https://mock.service.url",
+ mock_http_client,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=default_identity,
+ )
+
+ clone = client.from_agentic_identity(override_identity)
+
+ assert clone.http is not client.http
+ assert clone.http.http is client.http.http
+ assert clone._default_agentic_identity is override_identity
+
+ def test_clone_preserves_agentic_identity_with_explicit_none(self, mock_http_client):
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient("https://mock.service.url", mock_http_client, agentic_identity=identity)
+
+ clone = client.clone(agentic_identity=None)
+
+ assert clone._default_agentic_identity is identity
+
+ def test_clone_can_override_service_url_and_clear_agentic_identity(self, mock_http_client):
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient("https://mock.service.url", mock_http_client, agentic_identity=identity)
+
+ clone = client.clone(service_url="https://override.service.url/", agentic_identity=AGENTIC_IDENTITY_CLEAR)
+
+ assert clone.service_url == "https://override.service.url"
+ assert clone._default_agentic_identity is None
+
+ def test_scoped_helpers_create_expected_clones(self, mock_http_client):
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient("https://mock.service.url", mock_http_client)
+
+ service_scoped = client.from_service_url("https://override.service.url/")
+ identity_scoped = client.from_agentic_identity(identity)
+ alias_scoped = client.for_agentic_identity(identity)
+
+ assert service_scoped.service_url == "https://override.service.url"
+ assert identity_scoped._default_agentic_identity is identity
+ assert alias_scoped._default_agentic_identity is identity
+
+ @pytest.mark.asyncio
+ async def test_clone_uses_scoped_agentic_identity_for_auth(self, request_capture, mock_activity):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ default_identity = AgenticIdentity("default-app-id", "default-user-id", tenant_id="default-tenant-id")
+ override_identity = AgenticIdentity("override-app-id", "override-user-id", tenant_id="override-tenant-id")
+ client = ApiClient(
+ "https://test.service.url",
+ request_capture,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=default_identity,
+ )
+
+ await client.from_agentic_identity(override_identity).conversations.create_activity(
+ "test_conversation_id", mock_activity
+ )
+
+ assert calls == [(None, override_identity)]
+ request = request_capture._capture.last_request
+ assert "authorization" in request.headers
+
+ @pytest.mark.asyncio
+ async def test_clone_uses_token_for_each_scoped_agentic_identity(self, request_capture, mock_activity):
+ calls = []
+ identity_1 = AgenticIdentity("agentic-app-id-1", "agentic-user-id-1", tenant_id="tenant-id")
+ identity_2 = AgenticIdentity("agentic-app-id-2", "agentic-user-id-2", tenant_id="tenant-id")
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ if agentic_identity is identity_1:
+ return "token-1"
+ if agentic_identity is identity_2:
+ return "token-2"
+ return "default-token"
+
+ client = ApiClient(
+ "https://test.service.url",
+ request_capture,
+ auth_provider=TestAuthProvider(),
+ )
+
+ await client.from_agentic_identity(identity_1).conversations.create_activity(
+ "test_conversation_id", mock_activity
+ )
+ first_request = request_capture._capture.last_request
+ await client.from_agentic_identity(identity_2).conversations.create_activity(
+ "test_conversation_id", mock_activity
+ )
+ second_request = request_capture._capture.last_request
+
+ assert calls == [(None, identity_1), (None, identity_2)]
+ assert first_request.headers["authorization"] == "Bearer token-1"
+ assert second_request.headers["authorization"] == "Bearer token-2"
+
+ @pytest.mark.asyncio
+ async def test_chained_clone_uses_token_for_new_scoped_agentic_identity(self, request_capture, mock_activity):
+ calls = []
+ identity_1 = AgenticIdentity("agentic-app-id-1", "agentic-user-id-1", tenant_id="tenant-id")
+ identity_2 = AgenticIdentity("agentic-app-id-2", "agentic-user-id-2", tenant_id="tenant-id")
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ if agentic_identity is identity_1:
+ return "token-1"
+ if agentic_identity is identity_2:
+ return "token-2"
+ return "default-token"
+
+ client = ApiClient(
+ "https://test.service.url",
+ request_capture,
+ auth_provider=TestAuthProvider(),
+ )
+
+ await (
+ client.from_service_url("https://override.service.url")
+ .from_agentic_identity(identity_1)
+ .conversations.create_activity("test_conversation_id", mock_activity)
+ )
+ first_request = request_capture._capture.last_request
+ await (
+ client.from_agentic_identity(identity_1)
+ .from_agentic_identity(identity_2)
+ .conversations.create_activity("test_conversation_id", mock_activity)
+ )
+ second_request = request_capture._capture.last_request
+
+ assert calls == [(None, identity_1), (None, identity_2)]
+ assert first_request.headers["authorization"] == "Bearer token-1"
+ assert str(first_request.url).startswith("https://override.service.url/")
+ assert second_request.headers["authorization"] == "Bearer token-2"
+ assert str(second_request.url).startswith("https://test.service.url/")
+
+ @pytest.mark.asyncio
+ async def test_clone_none_preserves_scoped_agentic_identity(self, request_capture, mock_activity):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ default_identity = AgenticIdentity("default-app-id", "default-user-id", tenant_id="default-tenant-id")
+ client = ApiClient(
+ "https://test.service.url",
+ request_capture,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=default_identity,
+ )
+
+ await client.clone(agentic_identity=None).conversations.create_activity("test_conversation_id", mock_activity)
+
+ assert calls == [(None, default_identity)]
+
+ @pytest.mark.asyncio
+ async def test_clone_clear_clears_scoped_agentic_identity(self, request_capture, mock_activity):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ default_identity = AgenticIdentity("default-app-id", "default-user-id", tenant_id="default-tenant-id")
+ client = ApiClient(
+ "https://test.service.url",
+ request_capture,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=default_identity,
+ )
+
+ await client.clone(agentic_identity=AGENTIC_IDENTITY_CLEAR).conversations.create_activity(
+ "test_conversation_id", mock_activity
+ )
+
+ assert calls == [(None, None)]
+
+ def test_http_client_token_conflicts_with_auth_provider(self, request_capture):
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ return "agentic-token"
+
+ request_capture_with_token = request_capture.clone(ClientOptions(token="http-client-token"), share_http=True)
+
+ with pytest.raises(ValueError, match="auth provider and an HTTP client token"):
+ ApiClient(
+ "https://test.service.url",
+ request_capture_with_token,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id"),
+ )
diff --git a/packages/api/tests/unit/test_base_client.py b/packages/api/tests/unit/test_base_client.py
new file mode 100644
index 000000000..3e51a35c6
--- /dev/null
+++ b/packages/api/tests/unit/test_base_client.py
@@ -0,0 +1,192 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+import httpx
+import pytest
+from microsoft_teams.api.auth.cloud_environment import US_GOV
+from microsoft_teams.api.clients import ApiClient
+from microsoft_teams.api.clients.base_client import BaseClient
+from microsoft_teams.api.models import AgenticIdentity
+from microsoft_teams.common import Client, ClientOptions, Token
+
+
+class RequestRecorder:
+ def __init__(self):
+ self.requests: list[httpx.Request] = []
+
+ def handler(self, request: httpx.Request) -> httpx.Response:
+ self.requests.append(request)
+ return httpx.Response(200, json={"ok": True}, headers={"content-type": "application/json"})
+
+ @property
+ def last_request(self) -> httpx.Request:
+ return self.requests[-1]
+
+
+class RecordingAuthProvider:
+ def __init__(self, token_value: str | None = "auth-provider-token"):
+ self._token_value = token_value
+ self.calls: list[tuple[str | None, AgenticIdentity | None]] = []
+
+ def token(
+ self,
+ *,
+ scope: str | None = None,
+ agentic_identity: AgenticIdentity | None = None,
+ ) -> str | None:
+ self.calls.append((scope, agentic_identity))
+ return self._token_value
+
+
+class HarnessClient(BaseClient):
+ async def post_resource(
+ self,
+ *,
+ token: Token | None = None,
+ headers: dict[str, str] | None = None,
+ ) -> httpx.Response:
+ return await self.http.post(
+ "/resource",
+ json={"ok": True},
+ headers=headers,
+ token=token,
+ )
+
+
+def create_client(*, default_token: Token | None = None) -> tuple[Client, RequestRecorder]:
+ recorder = RequestRecorder()
+ client = Client(ClientOptions(base_url="https://mock.api.com", token=default_token))
+ client.http._transport = httpx.MockTransport(recorder.handler)
+ return client, recorder
+
+
+def create_auth_provider_harness(
+ auth_provider: RecordingAuthProvider,
+ default_agentic_identity: AgenticIdentity | None = None,
+) -> tuple[HarnessClient, RequestRecorder]:
+ http_client, recorder = create_client()
+ api_client = ApiClient(
+ "https://test.service.url",
+ http_client,
+ auth_provider=auth_provider,
+ agentic_identity=default_agentic_identity,
+ )
+ return HarnessClient(api_client.http), recorder
+
+
+def test_api_client_uses_http_token_for_auth_provider_without_mutating_source_client():
+ http_client, _ = create_client()
+ auth_provider = RecordingAuthProvider()
+
+ api_client = ApiClient("https://test.service.url", http_client, auth_provider=auth_provider)
+
+ assert http_client.token is None
+ assert api_client.http.token is not None
+ assert api_client.http.http is http_client.http
+ assert api_client.http.interceptors == http_client.interceptors
+
+
+def test_api_client_uses_cloud_token_service_url_for_default_settings():
+ client = ApiClient("https://test.service.url", cloud=US_GOV)
+
+ assert client._api_client_settings.oauth_url == US_GOV.token_service_url
+
+
+@pytest.mark.asyncio
+async def test_explicit_request_token_wins_over_auth_provider_and_http_client_token():
+ http_client, recorder = create_client()
+ auth_provider = RecordingAuthProvider()
+ api_client = ApiClient("https://test.service.url", http_client, auth_provider=auth_provider)
+ client = HarnessClient(api_client.http)
+
+ await client.post_resource(token="explicit-token")
+
+ assert auth_provider.calls == []
+ assert recorder.last_request.headers["authorization"] == "Bearer explicit-token"
+
+
+@pytest.mark.asyncio
+async def test_explicit_authorization_header_wins_over_auth_provider():
+ auth_provider = RecordingAuthProvider()
+ client, recorder = create_auth_provider_harness(auth_provider)
+
+ await client.post_resource(headers={"Authorization": "Bearer explicit-header-token"})
+
+ assert auth_provider.calls == []
+ assert recorder.last_request.headers["authorization"] == "Bearer explicit-header-token"
+
+
+def test_http_client_token_conflicts_with_auth_provider():
+ auth_provider = RecordingAuthProvider()
+ http_client, _ = create_client(default_token="http-client-token")
+
+ with pytest.raises(ValueError, match="auth provider and an HTTP client token"):
+ ApiClient("https://test.service.url", http_client, auth_provider=auth_provider)
+
+
+@pytest.mark.asyncio
+async def test_auth_provider_token_is_used_when_request_has_no_auth():
+ auth_provider = RecordingAuthProvider()
+ client, recorder = create_auth_provider_harness(auth_provider)
+
+ await client.post_resource()
+
+ assert auth_provider.calls == [(None, None)]
+ assert recorder.last_request.headers["authorization"] == "Bearer auth-provider-token"
+
+
+@pytest.mark.asyncio
+async def test_no_authorization_is_added_when_auth_provider_returns_none():
+ auth_provider = RecordingAuthProvider(token_value=None)
+ client, recorder = create_auth_provider_harness(auth_provider)
+
+ await client.post_resource()
+
+ assert auth_provider.calls == [(None, None)]
+ assert "authorization" not in recorder.last_request.headers
+
+
+@pytest.mark.asyncio
+async def test_http_client_token_is_used_when_no_auth_provider():
+ http_client, recorder = create_client(default_token="http-client-token")
+ client = HarnessClient(http_client)
+
+ await client.post_resource()
+
+ assert recorder.last_request.headers["authorization"] == "Bearer http-client-token"
+
+
+@pytest.mark.asyncio
+async def test_default_agentic_identity_is_used_without_request_metadata():
+ auth_provider = RecordingAuthProvider(token_value="agentic-token")
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client, recorder = create_auth_provider_harness(auth_provider, default_agentic_identity=identity)
+
+ await client.post_resource()
+
+ assert auth_provider.calls == [(None, identity)]
+ assert recorder.last_request.headers["authorization"] == "Bearer agentic-token"
+
+
+@pytest.mark.asyncio
+async def test_default_agentic_identity_is_passed_to_auth_provider_token():
+ auth_provider = RecordingAuthProvider(token_value="agentic-token")
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client, recorder = create_auth_provider_harness(auth_provider, default_agentic_identity=identity)
+
+ await client.post_resource()
+
+ assert auth_provider.calls == [(None, identity)]
+ assert recorder.last_request.headers["authorization"] == "Bearer agentic-token"
+
+
+@pytest.mark.asyncio
+async def test_http_client_token_still_wins_without_auth_provider():
+ http_client, recorder = create_client(default_token="http-client-token")
+ client = HarnessClient(http_client)
+
+ await client.post_resource()
+
+ assert recorder.last_request.headers["authorization"] == "Bearer http-client-token"
diff --git a/packages/api/tests/unit/test_bot_client.py b/packages/api/tests/unit/test_bot_client.py
index 3e2b17c28..c6a2e9ce4 100644
--- a/packages/api/tests/unit/test_bot_client.py
+++ b/packages/api/tests/unit/test_bot_client.py
@@ -5,7 +5,14 @@
# pyright: basic
import pytest
-from microsoft_teams.api import ApiClientSettings, BotClient, GetBotSignInResourceParams, GetBotSignInUrlParams
+from microsoft_teams.api import (
+ ApiClient,
+ ApiClientSettings,
+ BotClient,
+ GetBotSignInResourceParams,
+ GetBotSignInUrlParams,
+)
+from microsoft_teams.api.auth.credentials import TokenCredentials
from microsoft_teams.common.http import Client, ClientOptions
@@ -43,6 +50,57 @@ async def test_bot_token_get_graph_with_token_credentials(self, mock_http_client
assert response.access_token is not None
assert response.expires_in == -1
+ @pytest.mark.asyncio
+ async def test_bot_token_get_with_uninspectable_token_provider_signature(self, mock_http_client):
+ from unittest.mock import patch
+
+ calls = []
+
+ def token_provider(scope, tenant_id):
+ calls.append((scope, tenant_id))
+ return "token"
+
+ credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider)
+ client = BotClient(mock_http_client)
+
+ with patch("inspect.signature", side_effect=ValueError("no signature")):
+ response = await client.token.get(credentials)
+
+ assert response.access_token == "token"
+ assert calls == [("https://api.botframework.com/.default", "tenant-id")]
+
+ @pytest.mark.asyncio
+ async def test_bot_token_get_with_positional_agentic_identity_provider(self, mock_http_client):
+ calls = []
+
+ def token_provider(scope, tenant_id, agentic_identity):
+ calls.append((scope, tenant_id, agentic_identity))
+ return "token"
+
+ credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider)
+ client = BotClient(mock_http_client)
+
+ response = await client.token.get(credentials)
+
+ assert response.access_token == "token"
+ assert calls == [("https://api.botframework.com/.default", "tenant-id", None)]
+
+ @pytest.mark.asyncio
+ async def test_bot_token_get_with_optional_third_argument_uses_default(self, mock_http_client):
+ calls = []
+
+ def token_provider(scope, tenant_id, timeout=30):
+ calls.append((scope, tenant_id, timeout))
+ return "token"
+
+ credentials = TokenCredentials(client_id="client-id", tenant_id="tenant-id", token=token_provider)
+ client = BotClient(mock_http_client)
+
+ response = await client.token.get(credentials)
+
+ assert response.access_token == "token"
+ assert calls == [("https://api.botframework.com/.default", "tenant-id", 30)]
+
@pytest.mark.asyncio
async def test_bot_sign_in_get_url(self, mock_http_client):
client = BotClient(mock_http_client)
@@ -65,6 +123,24 @@ async def test_bot_sign_in_get_resource(self, mock_http_client):
assert response.sign_in_link.startswith("http")
assert response.token_exchange_resource is not None
+ @pytest.mark.asyncio
+ async def test_bot_sign_in_uses_auth_provider_for_bot_token(self, request_capture):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider())
+ params = GetBotSignInResourceParams(state="test_state", code_challenge="test_challenge")
+
+ await client.bots.sign_in.get_resource(params)
+
+ assert calls == [(None, None)]
+ request = request_capture._capture.last_request
+ assert request.headers["authorization"] == "Bearer bot-token"
+
@pytest.mark.unit
class TestBotClientHttpClientSharing:
@@ -106,6 +182,12 @@ def test_bot_token_client_receives_cloud(self):
assert client.token._cloud.bot_scope == "https://api.botframework.us/.default"
assert client.token._cloud.login_endpoint == "https://login.microsoftonline.us"
+ def test_bot_sign_in_client_uses_cloud_token_service_url(self):
+ from microsoft_teams.api.auth.cloud_environment import US_GOV
+
+ client = BotClient(cloud=US_GOV)
+ assert client.sign_in._api_client_settings.oauth_url == US_GOV.token_service_url
+
def test_bot_token_client_defaults_to_public(self):
from microsoft_teams.api.auth.cloud_environment import PUBLIC
diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py
index 7312ec17a..c22fa5414 100644
--- a/packages/api/tests/unit/test_conversation_client.py
+++ b/packages/api/tests/unit/test_conversation_client.py
@@ -9,9 +9,11 @@
import httpx
import pytest
+from microsoft_teams.api.auth.cloud_environment import PUBLIC, with_overrides
+from microsoft_teams.api.clients import ApiClient
from microsoft_teams.api.clients.conversation import ConversationClient
from microsoft_teams.api.clients.conversation.params import CreateConversationParams
-from microsoft_teams.api.models import ConversationResource, PagedMembersResult, TeamsChannelAccount
+from microsoft_teams.api.models import AgenticIdentity, ConversationResource, PagedMembersResult, TeamsChannelAccount
from microsoft_teams.common.http import Client, ClientOptions
@@ -100,6 +102,60 @@ async def test_create_conversation_without_activity(self, request_capture, mock_
assert last_request.method == "POST"
assert str(last_request.url) == "https://test.service.url/v3/conversations"
+ @pytest.mark.asyncio
+ async def test_create_conversation_uses_scoped_service_url(self, request_capture, mock_account):
+ client = (
+ ApiClient("https://test.service.url", request_capture)
+ .from_service_url("https://override.service.url/")
+ .conversations
+ )
+ params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id")
+
+ await client.create(params)
+
+ request = request_capture._capture.last_request
+ assert request.method == "POST"
+ assert str(request.url) == "https://override.service.url/v3/conversations"
+
+ @pytest.mark.asyncio
+ async def test_create_conversation_uses_auth_provider_for_bot_token(self, request_capture, mock_account):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations
+ params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id")
+
+ await client.create(params)
+
+ assert calls == [(None, None)]
+ request = request_capture._capture.last_request
+ assert request.headers["authorization"] == "Bearer bot-token"
+
+ @pytest.mark.asyncio
+ async def test_create_conversation_uses_agentic_identity(self, request_capture, mock_account):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://test.service.url", request_capture, auth_provider=TestAuthProvider(), agentic_identity=identity
+ ).conversations
+ params = CreateConversationParams(members=[mock_account], tenant_id="test_tenant_id")
+
+ await client.create(params)
+
+ assert calls == [(None, identity)]
+ request = request_capture._capture.last_request
+ assert request.headers["authorization"] == "Bearer agentic-token"
+
def test_conversation_resource_with_all_fields(self):
"""Test that ConversationResource correctly handles all fields present."""
resource = ConversationResource.model_validate(
@@ -199,6 +255,101 @@ async def test_activity_create(self, request_capture, mock_activity):
payload = json.loads(last_request.content)
assert payload["type"] == "message"
+ async def test_activity_create_with_scoped_service_url(self, request_capture, mock_activity):
+ """Test creating an activity with a scoped service URL."""
+ client = ApiClient("https://default.service.url", request_capture).from_service_url(
+ "https://override.service.url/"
+ )
+
+ await client.conversations.activities("test_conversation_id").create(mock_activity)
+
+ last_request = request_capture._capture.last_request
+ assert str(last_request.url) == "https://override.service.url/v3/conversations/test_conversation_id/activities"
+
+ async def test_activity_create_uses_auth_provider_for_bot_token(self, request_capture, mock_activity):
+ """Test creating an activity with an auth provider but no agentic identity."""
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations
+
+ await client.activities("test_conversation_id").create(mock_activity)
+
+ assert calls == [(None, None)]
+ last_request = request_capture._capture.last_request
+ assert last_request.headers["authorization"] == "Bearer bot-token"
+
+ async def test_activity_create_uses_client_agentic_identity(self, request_capture, mock_activity):
+ """Test creating an activity with the client's default agentic identity."""
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ cloud = with_overrides(PUBLIC, agentic_bot_scope="agentic-scope")
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://test.service.url",
+ request_capture,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=identity,
+ cloud=cloud,
+ ).conversations
+
+ await client.activities("test_conversation_id").create(mock_activity)
+
+ assert calls == [(None, identity)]
+ last_request = request_capture._capture.last_request
+ assert last_request.headers["authorization"] == "Bearer agentic-token"
+
+ async def test_activity_create_scoped_agentic_identity_overrides_client_default(
+ self, request_capture, mock_activity
+ ):
+ """Test scoped agentic identity overrides the client's default identity."""
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "override-token"
+
+ default_identity = AgenticIdentity("default-app-id", "default-user-id", tenant_id="default-tenant-id")
+ override_identity = AgenticIdentity("override-app-id", "override-user-id", tenant_id="override-tenant-id")
+ client = (
+ ApiClient(
+ "https://test.service.url",
+ request_capture,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=default_identity,
+ )
+ .from_agentic_identity(override_identity)
+ .conversations
+ )
+
+ await client.activities("test_conversation_id").create(mock_activity)
+
+ assert calls == [(None, override_identity)]
+ last_request = request_capture._capture.last_request
+ assert last_request.headers["authorization"] == "Bearer override-token"
+
+ async def test_activity_create_agentic_identity_without_auth_provider_uses_http_client_auth(
+ self, request_capture, mock_activity
+ ):
+ """Test agentic identity without an auth provider leaves auth resolution to the HTTP client."""
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient("https://test.service.url", request_capture).from_agentic_identity(identity).conversations
+
+ await client.activities("test_conversation_id").create(mock_activity)
+
+ last_request = request_capture._capture.last_request
+ assert "authorization" not in last_request.headers
+
async def test_activity_update(self, request_capture, mock_activity):
"""Test updating an activity."""
service_url = "https://test.service.url"
@@ -365,6 +516,74 @@ async def test_member_get(self, request_capture):
str(last_request.url) == f"https://test.service.url/v3/conversations/{conversation_id}/members/{member_id}"
)
+ async def test_member_operations_use_scoped_service_url(self, request_capture):
+ client = (
+ ApiClient("https://test.service.url", request_capture)
+ .from_service_url("https://override.service.url/")
+ .conversations
+ )
+ members = client.members("test_conversation_id")
+
+ await members.get_all()
+ await members.get("test_member_id")
+ await members.get_paged(page_size=10)
+
+ urls = [str(request.url) for request in request_capture._capture.requests[-3:]]
+ assert urls == [
+ "https://override.service.url/v3/conversations/test_conversation_id/members",
+ "https://override.service.url/v3/conversations/test_conversation_id/members/test_member_id",
+ "https://override.service.url/v3/conversations/test_conversation_id/pagedMembers?pageSize=10",
+ ]
+
+ async def test_member_operations_use_auth_provider_for_bot_token(self, request_capture):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", request_capture, auth_provider=TestAuthProvider()).conversations
+ members = client.members("test_conversation_id")
+
+ await members.get_all()
+ await members.get("test_member_id")
+ await members.get_paged(page_size=10)
+
+ assert calls == [
+ (None, None),
+ (None, None),
+ (None, None),
+ ]
+ for request in request_capture._capture.requests[-3:]:
+ assert request.headers["authorization"] == "Bearer bot-token"
+
+ async def test_member_operations_use_agentic_identity(self, request_capture):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://test.service.url", request_capture, auth_provider=TestAuthProvider(), agentic_identity=identity
+ ).conversations
+ members = client.members("test_conversation_id")
+
+ await members.get_all()
+ await members.get("test_member_id")
+ await members.get_paged(page_size=10)
+
+ assert calls == [
+ (None, identity),
+ (None, identity),
+ (None, identity),
+ ]
+ for request in request_capture._capture.requests[-3:]:
+ assert request.headers["authorization"] == "Bearer agentic-token"
+
async def test_member_get_paged(self, mock_http_client):
"""Test getting a page of members returns PagedMembersResult."""
diff --git a/packages/api/tests/unit/test_meeting_client.py b/packages/api/tests/unit/test_meeting_client.py
index 4f4e7d183..b27c24a1a 100644
--- a/packages/api/tests/unit/test_meeting_client.py
+++ b/packages/api/tests/unit/test_meeting_client.py
@@ -8,8 +8,10 @@
import httpx
import pytest
+from microsoft_teams.api.clients import ApiClient
from microsoft_teams.api.clients.meeting import MeetingClient
from microsoft_teams.api.models import (
+ AgenticIdentity,
MeetingInfo,
MeetingNotificationParams,
MeetingNotificationResponse,
@@ -48,6 +50,93 @@ async def test_get_participant(self, mock_http_client):
assert isinstance(result, MeetingParticipant)
+ @pytest.mark.asyncio
+ async def test_meeting_operations_use_scoped_service_url(self, mock_http_client):
+ client = (
+ ApiClient("https://test.service.url", mock_http_client)
+ .from_service_url("https://override.service.url/")
+ .meetings
+ )
+
+ meeting_response = httpx.Response(
+ 200,
+ json={
+ "id": "meeting-id",
+ "details": {
+ "id": "meeting-id",
+ "type": "meetingChat",
+ "joinUrl": "https://teams.microsoft.com/l/meetup-join/meeting-id",
+ "title": "Meeting",
+ "msGraphResourceId": "graph-resource-id",
+ },
+ },
+ headers={"content-type": "application/json"},
+ )
+ with patch.object(client.http, "get", new_callable=AsyncMock, return_value=meeting_response) as mock_get:
+ await client.get_by_id("meeting-id")
+
+ mock_get.assert_called_once_with("https://override.service.url/v1/meetings/meeting-id")
+
+ participant_response = httpx.Response(
+ 200,
+ json={"user": {"id": "participant-id"}},
+ headers={"content-type": "application/json"},
+ )
+ with patch.object(
+ client.http, "get", new_callable=AsyncMock, return_value=participant_response
+ ) as mock_get_participant:
+ await client.get_participant("meeting-id", "participant-id", "tenant-id")
+
+ mock_get_participant.assert_called_once_with(
+ "https://override.service.url/v1/meetings/meeting-id/participants/participant-id?tenantId=tenant-id",
+ )
+
+ params = MeetingNotificationParams(
+ value=MeetingNotificationValue(
+ recipients=["mock_aad_oid"],
+ surfaces=[MeetingNotificationSurface(surface="meetingTabIcon", tab_entity_id="test")],
+ )
+ )
+ notification_response = httpx.Response(202, content=b"", headers={"content-type": "application/json"})
+ with patch.object(client.http, "post", new_callable=AsyncMock, return_value=notification_response) as mock_post:
+ await client.send_notification("meeting-id", params)
+
+ mock_post.assert_called_once_with(
+ "https://override.service.url/v1/meetings/meeting-id/notification",
+ json=params.model_dump(by_alias=True, exclude_none=True),
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_by_id_uses_auth_provider_for_bot_token(self, mock_http_client):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).meetings
+ await client.get_by_id("meeting-id")
+
+ assert calls == [(None, None)]
+
+ @pytest.mark.asyncio
+ async def test_get_participant_uses_agentic_identity(self, mock_http_client):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://test.service.url", mock_http_client, auth_provider=TestAuthProvider(), agentic_identity=identity
+ ).meetings
+ await client.get_participant("meeting-id", "participant-id", "tenant-id")
+
+ assert calls == [(None, identity)]
+
def test_http_client_property(self, mock_http_client):
"""Test HTTP client property getter and setter."""
service_url = "https://test.service.url"
diff --git a/packages/api/tests/unit/test_message_activities.py b/packages/api/tests/unit/test_message_activities.py
index e95c210d5..c53182cbd 100644
--- a/packages/api/tests/unit/test_message_activities.py
+++ b/packages/api/tests/unit/test_message_activities.py
@@ -651,9 +651,7 @@ def _make_message_update_payload(self, msg_id: str = "msg-123", **overrides) ->
def test_message_update_with_attachments_from_json(self):
"""Test that inbound messageUpdate with attachments parses them as Attachment objects."""
- payload = self._make_message_update_payload(
- attachments=[{"contentType": "text/html", "content": "hey\n\n"}]
- )
+ payload = self._make_message_update_payload(attachments=[{"contentType": "text/html", "content": "hey\n\n"}])
activity = ActivityTypeAdapter.validate_python(payload)
diff --git a/packages/api/tests/unit/test_reaction_client.py b/packages/api/tests/unit/test_reaction_client.py
index f17868847..a2e8be04c 100644
--- a/packages/api/tests/unit/test_reaction_client.py
+++ b/packages/api/tests/unit/test_reaction_client.py
@@ -7,7 +7,10 @@
from unittest.mock import AsyncMock, patch
import pytest
+from microsoft_teams.api.auth.cloud_environment import PUBLIC, with_overrides
+from microsoft_teams.api.clients import ApiClient
from microsoft_teams.api.clients.reaction import ReactionClient
+from microsoft_teams.api.models import AgenticIdentity
@pytest.mark.unit
@@ -55,6 +58,64 @@ async def test_add_reaction(self, mock_http_client):
)
mock_put.assert_called_once_with(expected_url)
+ @pytest.mark.asyncio
+ async def test_reaction_operations_use_scoped_service_url(self, mock_http_client):
+ client = ReactionClient("https://override.service.url/", mock_http_client)
+
+ with patch.object(mock_http_client, "put", new_callable=AsyncMock) as mock_put:
+ await client.add("test_conversation_id", "test_activity_id", "like")
+
+ mock_put.assert_called_once_with(
+ "https://override.service.url/v3/conversations/test_conversation_id/activities/test_activity_id/reactions/like",
+ )
+
+ with patch.object(mock_http_client, "delete", new_callable=AsyncMock) as mock_delete:
+ await client.delete("test_conversation_id", "test_activity_id", "like")
+
+ mock_delete.assert_called_once_with(
+ "https://override.service.url/v3/conversations/test_conversation_id/activities/test_activity_id/reactions/like",
+ )
+
+ @pytest.mark.asyncio
+ async def test_add_reaction_uses_agentic_identity(self, mock_http_client):
+ """Test adding a reaction with an agentic token."""
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ cloud = with_overrides(PUBLIC, agentic_bot_scope="agentic-scope")
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://test.service.url",
+ mock_http_client,
+ auth_provider=TestAuthProvider(),
+ agentic_identity=identity,
+ cloud=cloud,
+ ).reactions
+
+ await client.add("test_conversation_id", "test_activity_id", "like")
+
+ assert calls == [(None, identity)]
+
+ @pytest.mark.asyncio
+ async def test_add_reaction_uses_auth_provider_for_bot_token(self, mock_http_client):
+ """Test adding a reaction with an auth provider but no agentic identity."""
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).reactions
+
+ await client.add("test_conversation_id", "test_activity_id", "like")
+
+ assert calls == [(None, None)]
+
@pytest.mark.asyncio
async def test_add_heart_reaction(self, mock_http_client):
"""Test adding a heart reaction to an activity."""
@@ -91,6 +152,25 @@ async def test_delete_reaction(self, mock_http_client):
)
mock_delete.assert_called_once_with(expected_url)
+ @pytest.mark.asyncio
+ async def test_delete_reaction_uses_scoped_agentic_identity(self, mock_http_client):
+ """Test removing a reaction with a scoped agentic token."""
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://test.service.url", mock_http_client, auth_provider=TestAuthProvider(), agentic_identity=identity
+ ).reactions
+
+ await client.delete("test_conversation_id", "test_activity_id", "like")
+
+ assert calls == [(None, identity)]
+
@pytest.mark.asyncio
async def test_delete_laugh_reaction(self, mock_http_client):
"""Test removing a laugh reaction from an activity."""
diff --git a/packages/api/tests/unit/test_team_client.py b/packages/api/tests/unit/test_team_client.py
index a47b5edb1..67b4ad773 100644
--- a/packages/api/tests/unit/test_team_client.py
+++ b/packages/api/tests/unit/test_team_client.py
@@ -4,9 +4,13 @@
"""
# pyright: basic
+from unittest.mock import AsyncMock, patch
+
+import httpx
import pytest
+from microsoft_teams.api.clients import ApiClient
from microsoft_teams.api.clients.team import TeamClient
-from microsoft_teams.api.models import ChannelInfo, TeamDetails
+from microsoft_teams.api.models import AgenticIdentity, ChannelInfo, TeamDetails
from microsoft_teams.common.http import Client, ClientOptions
@@ -37,6 +41,69 @@ async def test_get_conversations(self, mock_http_client):
assert isinstance(result, list)
assert all(isinstance(channel, ChannelInfo) for channel in result)
+ @pytest.mark.asyncio
+ async def test_team_operations_use_scoped_service_url(self, mock_http_client):
+ client = (
+ ApiClient("https://test.service.url", mock_http_client)
+ .from_service_url("https://override.service.url/")
+ .teams
+ )
+
+ team_response = httpx.Response(
+ 200,
+ json={"id": "team-id", "name": "Team"},
+ headers={"content-type": "application/json"},
+ )
+ with patch.object(client.http, "get", new_callable=AsyncMock, return_value=team_response) as mock_get:
+ await client.get_by_id("team-id")
+
+ mock_get.assert_called_once_with("https://override.service.url/v3/teams/team-id")
+
+ conversations_response = httpx.Response(
+ 200,
+ json={"conversations": []},
+ headers={"content-type": "application/json"},
+ )
+ with patch.object(
+ client.http, "get", new_callable=AsyncMock, return_value=conversations_response
+ ) as mock_get_conversations:
+ await client.get_conversations("team-id")
+
+ mock_get_conversations.assert_called_once_with(
+ "https://override.service.url/v3/teams/team-id/conversations",
+ )
+
+ @pytest.mark.asyncio
+ async def test_get_by_id_uses_auth_provider_for_bot_token(self, mock_http_client):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).teams
+ await client.get_by_id("team-id")
+
+ assert calls == [(None, None)]
+
+ @pytest.mark.asyncio
+ async def test_get_conversations_uses_agentic_identity(self, mock_http_client):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "agentic-token"
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ client = ApiClient(
+ "https://test.service.url", mock_http_client, auth_provider=TestAuthProvider(), agentic_identity=identity
+ ).teams
+ await client.get_conversations("team-id")
+
+ assert calls == [(None, identity)]
+
def test_http_client_property(self, mock_http_client):
"""Test HTTP client property getter and setter."""
service_url = "https://test.service.url"
diff --git a/packages/api/tests/unit/test_user_client.py b/packages/api/tests/unit/test_user_client.py
index 4e793179e..863e405a7 100644
--- a/packages/api/tests/unit/test_user_client.py
+++ b/packages/api/tests/unit/test_user_client.py
@@ -7,6 +7,7 @@
import pytest
from microsoft_teams.api import (
+ ApiClient,
ApiClientSettings,
ExchangeUserTokenParams,
GetUserAADTokenParams,
@@ -37,6 +38,26 @@ async def test_user_token_get(self, mock_http_client):
assert response.token == "mock_access_token_123"
assert response.connection_name == "test_connection"
+ @pytest.mark.asyncio
+ async def test_user_token_get_uses_auth_provider_for_bot_token(self, mock_http_client):
+ calls = []
+
+ class TestAuthProvider:
+ def token(self, *, scope=None, agentic_identity=None):
+ calls.append((scope, agentic_identity))
+ return "bot-token"
+
+ client = ApiClient("https://test.service.url", mock_http_client, auth_provider=TestAuthProvider()).users
+ params = GetUserTokenParams(
+ user_id="test_user_id",
+ connection_name="test_connection",
+ channel_id="test_channel_id",
+ )
+
+ await client.token.get(params)
+
+ assert calls == [(None, None)]
+
@pytest.mark.asyncio
async def test_user_token_get_aad(self, mock_http_client):
client = UserClient(mock_http_client)
@@ -126,6 +147,15 @@ def test_http_client_update_propagates(self, mock_http_client):
assert client.token.http == new_http_client
+@pytest.mark.unit
+class TestUserClientSovereignCloud:
+ def test_user_token_client_uses_cloud_token_service_url(self):
+ from microsoft_teams.api.auth.cloud_environment import US_GOV
+
+ client = UserClient(cloud=US_GOV)
+ assert client.token._api_client_settings.oauth_url == US_GOV.token_service_url
+
+
@pytest.mark.unit
class TestUserClientRegionalEndpoints:
@pytest.mark.asyncio
diff --git a/packages/apps/pyproject.toml b/packages/apps/pyproject.toml
index c27b9db69..336c07c54 100644
--- a/packages/apps/pyproject.toml
+++ b/packages/apps/pyproject.toml
@@ -14,10 +14,10 @@ dependencies = [
"microsoft-teams-api",
"microsoft-teams-common",
"uvicorn>=0.34.3",
- "cryptography>=3.4.0",
+ "cryptography>=48.0.1",
"pyjwt[crypto]>=2.12.0",
"dependency-injector>=4.48.1",
- "msal>=1.33.0",
+ "msal>=1.37.0",
"python-dotenv>=1.0.0",
"pydantic-settings>=2.11.0",
]
diff --git a/packages/apps/src/microsoft_teams/apps/activity_send.py b/packages/apps/src/microsoft_teams/apps/activity_send.py
new file mode 100644
index 000000000..d09debe1f
--- /dev/null
+++ b/packages/apps/src/microsoft_teams/apps/activity_send.py
@@ -0,0 +1,52 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from microsoft_teams.api import (
+ ActivityParams,
+ AgenticIdentity,
+ ApiClient,
+ ConversationReference,
+ MessageActivityInput,
+ SentActivity,
+)
+
+
+async def send_or_update_activity(
+ api: ApiClient,
+ activity: ActivityParams,
+ ref: ConversationReference,
+ *,
+ agentic_identity: AgenticIdentity | None = None,
+) -> SentActivity:
+ """Send or update an activity using the same routing rules as the removed ActivitySender."""
+ is_targeted = (
+ isinstance(activity, MessageActivityInput)
+ and activity.recipient is not None
+ and activity.recipient.is_targeted is True
+ )
+
+ if is_targeted and ref.conversation.conversation_type == "personal":
+ raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.")
+
+ activity.from_ = ref.bot
+ activity.conversation = ref.conversation
+ scoped_api = (
+ api
+ if agentic_identity is None and ref.service_url.rstrip("/") == api.service_url
+ else api.clone(service_url=ref.service_url, agentic_identity=agentic_identity)
+ )
+ if activity.id:
+ activity_id = activity.id
+ if is_targeted:
+ res = await scoped_api.conversations.update_targeted_activity(ref.conversation.id, activity_id, activity)
+ else:
+ res = await scoped_api.conversations.update_activity(ref.conversation.id, activity_id, activity)
+ return SentActivity.merge(activity, res)
+
+ if is_targeted:
+ res = await scoped_api.conversations.create_targeted_activity(ref.conversation.id, activity)
+ else:
+ res = await scoped_api.conversations.create_activity(ref.conversation.id, activity)
+ return SentActivity.merge(activity, res)
diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py
deleted file mode 100644
index 6bc03aefd..000000000
--- a/packages/apps/src/microsoft_teams/apps/activity_sender.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""
-Copyright (c) Microsoft Corporation. All rights reserved.
-Licensed under the MIT License.
-"""
-
-import logging
-from typing import cast
-
-from microsoft_teams.api import (
- ActivityParams,
- ApiClient,
- ConversationReference,
- MessageActivityInput,
- SentActivity,
-)
-from microsoft_teams.common import Client
-
-from .http_stream import HttpStream
-from .plugins.streamer import StreamerProtocol
-
-logger = logging.getLogger(__name__)
-
-
-class ActivitySender:
- """
- Handles sending activities to the Bot Framework.
- Separate from transport concerns (HTTP, WebSocket, etc.)
- """
-
- def __init__(self, client: Client):
- """
- Initialize ActivitySender.
-
- Args:
- client: HTTP client with token provider configured
- """
- self._client = client
-
- async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity:
- """
- Send an activity to the Bot Framework.
-
- Args:
- activity: The activity to send
- ref: The conversation reference
-
- Returns:
- The sent activity with id and other server-populated fields
- """
- is_targeted = (
- isinstance(activity, MessageActivityInput)
- and activity.recipient is not None
- and activity.recipient.is_targeted is True
- )
-
- if is_targeted and ref.conversation.conversation_type == "personal":
- raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.")
-
- # Create API client for this conversation's service URL
- api = ApiClient(service_url=ref.service_url, options=self._client)
-
- # Merge activity with conversation reference
- activity.from_ = ref.bot
- activity.conversation = ref.conversation
-
- is_update = hasattr(activity, "id") and activity.id
- conversation_id = ref.conversation.id
-
- if is_update:
- activity_id = cast(str, activity.id)
- if is_targeted:
- res = await api.conversations.update_targeted_activity(conversation_id, activity_id, activity)
- else:
- res = await api.conversations.update_activity(conversation_id, activity_id, activity)
- return SentActivity.merge(activity, res)
-
- if is_targeted:
- res = await api.conversations.create_targeted_activity(conversation_id, activity)
- else:
- res = await api.conversations.create_activity(conversation_id, activity)
- return SentActivity.merge(activity, res)
-
- def create_stream(self, ref: ConversationReference) -> StreamerProtocol:
- """
- Create a new activity stream for real-time updates.
-
- Args:
- ref: The conversation reference
-
- Returns:
- A new streaming instance
- """
- # Create API client for this conversation's service URL
- api = ApiClient(ref.service_url, self._client)
- return HttpStream(api, ref)
diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py
index b1f12c438..7a8c049b3 100644
--- a/packages/apps/src/microsoft_teams/apps/app.py
+++ b/packages/apps/src/microsoft_teams/apps/app.py
@@ -15,6 +15,7 @@
Account,
ActivityBase,
ActivityParams,
+ AgenticIdentity,
ApiClient,
ClientCredentials,
ConversationAccount,
@@ -35,13 +36,14 @@
if TYPE_CHECKING:
from msgraph.graph_service_client import GraphServiceClient
-from .activity_sender import ActivitySender
+from .activity_send import send_or_update_activity
from .app_events import EventManager
from .app_oauth import OauthHandlers
from .app_plugins import PluginProcessor
from .app_process import ActivityProcessor
from .auth import TokenValidator
from .auth.remote_function_jwt_middleware import validate_remote_function_request
+from .auth_provider import AppAuthProvider
from .container import Container
from .contexts.function_context import FunctionContext
from .events import (
@@ -101,6 +103,7 @@ def __init__(self, **options: Unpack[AppOptions]):
credentials=self.credentials,
cloud=self.cloud,
)
+ self._auth_provider = AppAuthProvider(self._token_manager, self.cloud)
self.container = Container()
self.container.set_provider("storage", providers.Object(self.storage))
@@ -111,9 +114,10 @@ def __init__(self, **options: Unpack[AppOptions]):
self.api = ApiClient(
service_url,
- self.http_client.clone(ClientOptions(token=self._get_bot_token)),
+ self.http_client.clone(),
self.options.api_client_settings,
cloud=self.cloud,
+ auth_provider=self._auth_provider,
)
plugins: List[PluginBase] = list(self.options.plugins)
@@ -126,9 +130,6 @@ def __init__(self, **options: Unpack[AppOptions]):
self._port: Optional[int] = None
self._initialized = False
- # initialize ActivitySender for sending activities
- self.activity_sender = ActivitySender(self.http_client.clone(ClientOptions(token=self._get_bot_token)))
-
# initialize all event, activity, and plugin processors
self.activity_processor = ActivityProcessor(
self._router,
@@ -137,8 +138,8 @@ 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.activity_sender,
self.cloud,
)
self.event_manager = EventManager(self._events)
@@ -291,7 +292,13 @@ 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,
+ *,
+ agentic_identity: Optional[AgenticIdentity] = None,
+ ) -> SentActivity:
"""Send an activity proactively to a conversation.
Sends to the exact conversation ID provided. For channel threads,
@@ -319,7 +326,37 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap
else:
activity = activity
- return await self.activity_sender.send(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.
+
+ When ``agentic_app_blueprint_id`` is omitted, it defaults to the app's own
+ client/app id (``self.id``).
+ """
+ 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")
+
+ resolved_blueprint_id = agentic_app_blueprint_id or self.id
+ return AgenticIdentity(
+ agentic_app_id=agentic_app_id,
+ agentic_user_id=agentic_user_id,
+ tenant_id=resolved_tenant_id,
+ agentic_app_blueprint_id=resolved_blueprint_id,
+ )
@overload
async def reply(
@@ -327,6 +364,8 @@ async def reply(
conversation_id: str,
message_id: str,
activity: str | ActivityParams | AdaptiveCard,
+ *,
+ agentic_identity: Optional[AgenticIdentity] = None,
) -> SentActivity: ...
@overload
@@ -334,6 +373,8 @@ async def reply(
self,
conversation_id: str,
message_id: str | ActivityParams | AdaptiveCard,
+ *,
+ agentic_identity: Optional[AgenticIdentity] = None,
) -> SentActivity: ...
async def reply( # type: ignore[reportInconsistentOverload]
@@ -341,6 +382,8 @@ async def reply( # type: ignore[reportInconsistentOverload]
conversation_id: str,
message_id: str | ActivityParams | AdaptiveCard = "",
activity: str | ActivityParams | AdaptiveCard | None = None,
+ *,
+ agentic_identity: Optional[AgenticIdentity] = None,
) -> SentActivity:
"""Send an activity proactively to a conversation, optionally as a threaded reply.
@@ -361,9 +404,17 @@ 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,
+ agentic_identity=agentic_identity,
+ )
- return await self.send(conversation_id, message_id)
+ return await self.send(
+ conversation_id,
+ message_id,
+ agentic_identity=agentic_identity,
+ )
def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None:
"""Add middleware to run on all activities."""
@@ -572,7 +623,6 @@ async def handler(request: HttpRequest) -> HttpResponse:
ctx = FunctionContext(
id=self.id,
api=self.api,
- activity_sender=self.activity_sender,
data=request["body"],
**client_context.__dict__,
)
diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py
index a7c420833..e1dc18965 100644
--- a/packages/apps/src/microsoft_teams/apps/app_process.py
+++ b/packages/apps/src/microsoft_teams/apps/app_process.py
@@ -21,12 +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 .activity_sender import ActivitySender
+from .auth_provider import AppAuthProvider
from .events import ActivityEvent, ActivityResponseEvent, ActivitySentEvent, ErrorEvent
from .plugins import PluginActivityEvent, PluginBase, StreamCancelledError
from .routing.activity_context import ActivityContext
@@ -48,8 +48,8 @@ def __init__(
default_connection_name: str,
http_client: Client,
token_manager: TokenManager,
+ auth_provider: AppAuthProvider,
api_client_settings: Optional[ApiClientSettings],
- activity_sender: ActivitySender,
cloud: CloudEnvironment = PUBLIC,
) -> None:
self.router = router
@@ -58,8 +58,8 @@ 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.activity_sender = activity_sender
self.cloud = cloud
# This will be set after the EventManager is initialized due to
@@ -93,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,
)
# Check if user is signed in
@@ -127,7 +129,6 @@ async def _build_context(
conversation_ref,
is_signed_in,
self.default_connection_name,
- activity_sender=self.activity_sender,
app_token=lambda: self.token_manager.get_graph_token(tenant_id),
cloud=self.cloud,
)
diff --git a/packages/apps/src/microsoft_teams/apps/auth/__init__.py b/packages/apps/src/microsoft_teams/apps/auth/__init__.py
index f64ff6335..bf7b38ffe 100644
--- a/packages/apps/src/microsoft_teams/apps/auth/__init__.py
+++ b/packages/apps/src/microsoft_teams/apps/auth/__init__.py
@@ -4,6 +4,6 @@
"""
from .remote_function_jwt_middleware import validate_remote_function_request
-from .token_validator import TokenValidator
+from .token_validator import InboundActivityTokenValidator, TokenValidator
-__all__ = ["TokenValidator", "validate_remote_function_request"]
+__all__ = ["InboundActivityTokenValidator", "TokenValidator", "validate_remote_function_request"]
diff --git a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py
index a4f1df1d4..437500600 100644
--- a/packages/apps/src/microsoft_teams/apps/auth/token_validator.py
+++ b/packages/apps/src/microsoft_teams/apps/auth/token_validator.py
@@ -14,6 +14,8 @@
from microsoft_teams.api.auth.cloud_environment import PUBLIC, CloudEnvironment
JWT_LEEWAY_SECONDS = 300 # Allowable clock skew when validating JWTs
+_MAX_ENTRA_VALIDATOR_CACHE_SIZE = 100
+ENTRA_V1_ISSUER_PREFIX = "https://sts.windows.net/"
logger = logging.getLogger(__name__)
@@ -113,7 +115,7 @@ def for_entra(
# are still issued with the v1 issuer.
# See: https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens
valid_issuers.append(f"{env.login_endpoint}/{tenant_id}/v2.0")
- valid_issuers.append(f"https://sts.windows.net/{tenant_id}/")
+ valid_issuers.append(f"{ENTRA_V1_ISSUER_PREFIX}{tenant_id}/")
else:
logger.warning(
"No tenant_id provided for Entra token validation. "
@@ -222,3 +224,61 @@ def _validate_scope(self, payload: Dict[str, Any], required_scope: str) -> None:
if required_scope not in scope_set:
logger.error(f"Token missing required scope: {required_scope}")
raise jwt.InvalidTokenError(f"Token missing required scope: {required_scope}")
+
+
+class InboundActivityTokenValidator:
+ """Validator for inbound Teams activities.
+
+ Classic bot activities use Bot Framework connector tokens. Agent ID activities use
+ Entra tokens whose audience is the agent identity blueprint app ID.
+ """
+
+ def __init__(self, app_id: str, cloud: Optional[CloudEnvironment] = None):
+ self._app_id = app_id
+ self._cloud = cloud or PUBLIC
+ self._service_validator = TokenValidator.for_service(app_id, cloud=self._cloud)
+ self._entra_validators_by_tenant: dict[str, TokenValidator] = {}
+
+ async def validate_token(self, raw_token: str, service_url: Optional[str] = None) -> Dict[str, Any]:
+ if not raw_token:
+ logger.error("No token provided")
+ raise jwt.InvalidTokenError("No token provided")
+
+ unverified_payload = jwt.decode(raw_token, algorithms=["RS256"], options={"verify_signature": False})
+ issuer = unverified_payload.get("iss", "")
+ if self._is_entra_issuer(issuer):
+ return await self._validate_entra_token(raw_token, unverified_payload)
+
+ return await self._service_validator.validate_token(raw_token, service_url)
+
+ def _is_entra_issuer(self, issuer: Any) -> bool:
+ if not isinstance(issuer, str):
+ return False
+
+ return issuer.startswith(self._cloud.login_endpoint) or issuer.startswith(ENTRA_V1_ISSUER_PREFIX)
+
+ async def _validate_entra_token(self, raw_token: str, unverified_payload: Dict[str, Any]) -> Dict[str, Any]:
+ tenant_id = unverified_payload.get("tid")
+ if not tenant_id or not isinstance(tenant_id, str):
+ raise jwt.InvalidTokenError("Entra inbound token is missing tid")
+
+ validator = self._get_entra_validator(tenant_id)
+ # TODO: Agent ID inbound Entra tokens currently do not include serviceurl. Revisit service URL
+ # validation for this path once the platform defines a signed service URL claim or equivalent.
+ return await validator.validate_token(raw_token)
+
+ def _get_entra_validator(self, tenant_id: str) -> TokenValidator:
+ cached_validator = self._entra_validators_by_tenant.get(tenant_id)
+ if cached_validator:
+ return cached_validator
+
+ validator = TokenValidator.for_entra(
+ self._app_id,
+ tenant_id,
+ cloud=self._cloud,
+ )
+ self._entra_validators_by_tenant[tenant_id] = validator
+ if len(self._entra_validators_by_tenant) > _MAX_ENTRA_VALIDATOR_CACHE_SIZE:
+ oldest_tenant_id = next(iter(self._entra_validators_by_tenant))
+ self._entra_validators_by_tenant.pop(oldest_tenant_id)
+ return validator
diff --git a/packages/apps/src/microsoft_teams/apps/auth_provider.py b/packages/apps/src/microsoft_teams/apps/auth_provider.py
new file mode 100644
index 000000000..69cc65a1b
--- /dev/null
+++ b/packages/apps/src/microsoft_teams/apps/auth_provider.py
@@ -0,0 +1,35 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from microsoft_teams.api import AgenticIdentity, TokenProtocol
+from microsoft_teams.api.auth.cloud_environment import CloudEnvironment
+
+from .token_manager import TokenManager
+
+
+class AppAuthProvider:
+ """Provides app and agentic tokens for Teams API clients."""
+
+ def __init__(self, token_manager: TokenManager, cloud: CloudEnvironment):
+ self._token_manager = token_manager
+ self._cloud = cloud
+
+ async def token(
+ self, *, scope: str | None = None, agentic_identity: AgenticIdentity | None = None
+ ) -> TokenProtocol | None:
+ if agentic_identity is None:
+ return await self._token_manager.get_app_token(
+ scope or self._cloud.bot_scope,
+ caller_name="token",
+ )
+
+ return await self._token_manager.get_agentic_token(
+ scope or self._cloud.agentic_bot_scope,
+ agentic_identity,
+ caller_name="token",
+ )
+
+
+__all__ = ["AppAuthProvider"]
diff --git a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py
index 2ed1c2a70..b3c37511b 100644
--- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py
+++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py
@@ -21,7 +21,7 @@
)
from microsoft_teams.cards import AdaptiveCard
-from ..activity_sender import ActivitySender
+from ..activity_send import send_or_update_activity
from .client_context import ClientContext
T = TypeVar("T")
@@ -44,9 +44,6 @@ class FunctionContext(ClientContext, Generic[T]):
api: ApiClient
"""The API client instance for conversation client."""
- activity_sender: ActivitySender
- """The activity sender instance for sending messages."""
-
data: T
"""The function payload."""
@@ -65,13 +62,6 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[
logger.warning("Cannot send activity: conversation ID could not be resolved")
return None
- conversation_ref = ConversationReference(
- channel_id="msteams",
- service_url=self.api.service_url,
- bot=Account(id=self.id, name=self.name),
- conversation=ConversationAccount(id=conversation_id, conversation_type="personal"),
- )
-
if isinstance(activity, str):
activity = MessageActivityInput(text=activity)
elif isinstance(activity, AdaptiveCard):
@@ -79,7 +69,13 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[
else:
activity = activity
- return await self.activity_sender.send(activity, conversation_ref)
+ conversation_ref = ConversationReference(
+ channel_id="msteams",
+ service_url=self.api.service_url,
+ bot=Account(id=self.id, name=self.name),
+ conversation=ConversationAccount(id=conversation_id, conversation_type="personal"),
+ )
+ return await send_or_update_activity(self.api, activity, conversation_ref)
async def _resolve_conversation_id(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[str]:
"""Resolve or create a conversation ID for the current user/context.
diff --git a/packages/apps/src/microsoft_teams/apps/http/http_server.py b/packages/apps/src/microsoft_teams/apps/http/http_server.py
index d29f6f7d5..61211019c 100644
--- a/packages/apps/src/microsoft_teams/apps/http/http_server.py
+++ b/packages/apps/src/microsoft_teams/apps/http/http_server.py
@@ -13,7 +13,7 @@
from microsoft_teams.api.auth.json_web_token import JsonWebToken
from pydantic import BaseModel
-from ..auth import TokenValidator
+from ..auth import InboundActivityTokenValidator
from ..events import ActivityEvent, CoreActivity
from .adapter import HttpRequest, HttpResponse, HttpServerAdapter
@@ -43,7 +43,7 @@ def __init__(self, adapter: HttpServerAdapter, messaging_endpoint: str = "/api/m
raise ValueError("messaging_endpoint must be a non-empty path starting with '/'.")
self._messaging_endpoint = normalized_endpoint
self._on_request: Optional[Callable[[ActivityEvent], Awaitable[InvokeResponse[Any]]]] = None
- self._token_validator: Optional[TokenValidator] = None
+ self._token_validator: Optional[InboundActivityTokenValidator] = None
self._dangerously_allow_unauthenticated_requests: bool = False
self._cloud: CloudEnvironment = PUBLIC
self._initialized: bool = False
@@ -89,7 +89,7 @@ def initialize(
app_id = getattr(credentials, "client_id", None) if credentials else None
if app_id and not dangerously_allow_unauthenticated_requests:
- self._token_validator = TokenValidator.for_service(
+ self._token_validator = InboundActivityTokenValidator(
app_id,
cloud=self._cloud,
)
diff --git a/packages/apps/src/microsoft_teams/apps/http_stream.py b/packages/apps/src/microsoft_teams/apps/http_stream.py
index cc25448fa..bdcc17dbc 100644
--- a/packages/apps/src/microsoft_teams/apps/http_stream.py
+++ b/packages/apps/src/microsoft_teams/apps/http_stream.py
@@ -57,8 +57,8 @@ def __init__(self, client: ApiClient, ref: ConversationReference):
ref (ConversationReference): Reference to the Teams conversation.
"""
super().__init__()
- self._client = client
self._ref = ref
+ self._client = client.from_service_url(ref.service_url)
self._events = EventEmitter[StreamerEvent]()
self._result: Optional[SentActivity] = None
diff --git a/packages/apps/src/microsoft_teams/apps/options.py b/packages/apps/src/microsoft_teams/apps/options.py
index 28ae9fbdb..57e059d71 100644
--- a/packages/apps/src/microsoft_teams/apps/options.py
+++ b/packages/apps/src/microsoft_teams/apps/options.py
@@ -8,10 +8,11 @@
import os
import warnings
from dataclasses import dataclass, field
-from typing import Any, Awaitable, Callable, List, Optional, TypedDict, Union, cast
+from typing import Any, List, Optional, TypedDict, Union, cast
from microsoft_teams.api import ApiClientSettings
from microsoft_teams.api.auth.cloud_environment import CloudEnvironment
+from microsoft_teams.api.auth.credentials import TokenProvider
from microsoft_teams.common import Client, ClientOptions, Storage
from typing_extensions import Unpack
@@ -60,7 +61,7 @@ class AppOptions(TypedDict, total=False):
"""Application ID URI from the Azure portal. Used for user authentication.
Matches webApplicationInfo.resource in the app manifest."""
# Custom token provider function
- token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]]
+ token: Optional[TokenProvider]
"""Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials."""
# Managed identity configuration (used when client_id provided without client_secret or token)
@@ -148,7 +149,7 @@ class InternalAppOptions:
application_id_uri: Optional[str] = None
"""Application ID URI from the Azure portal. Used for user authentication.
Matches webApplicationInfo.resource in the app manifest."""
- token: Optional[Callable[[Union[str, list[str]], Optional[str]], Union[str, Awaitable[str]]]] = None
+ token: Optional[TokenProvider] = None
"""Custom token provider function. If provided with client_id (no client_secret), uses TokenCredentials."""
managed_identity_client_id: Optional[str] = None
"""
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 ade5171dd..3abfc15fc 100644
--- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py
+++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py
@@ -41,7 +41,9 @@
from microsoft_teams.common.experimental import ExperimentalWarning
from microsoft_teams.common.http.client_token import Token
-from ..activity_sender import ActivitySender
+from ..activity_send import send_or_update_activity
+from ..http_stream import HttpStream
+from ..plugins.streamer import StreamerProtocol
from ..utils import create_graph_client
if TYPE_CHECKING:
@@ -86,7 +88,6 @@ def __init__(
conversation_ref: ConversationReference,
is_signed_in: bool,
connection_name: str,
- activity_sender: ActivitySender,
app_token: Token,
cloud: CloudEnvironment = PUBLIC,
):
@@ -100,9 +101,8 @@ def __init__(
self.connection_name = connection_name
self.is_signed_in = is_signed_in
self.cloud = cloud
- self._activity_sender = activity_sender
self._app_token = app_token
- self.stream = activity_sender.create_stream(conversation_ref)
+ self._stream: Optional[StreamerProtocol] = None
self._next_handler: Optional[Callable[[], Awaitable[None]]] = None
@@ -110,6 +110,12 @@ def __init__(
self._user_graph: Optional["GraphServiceClient"] = None
self._app_graph: Optional["GraphServiceClient"] = None
+ @property
+ def stream(self) -> StreamerProtocol:
+ if self._stream is None:
+ self._stream = HttpStream(self.api, self.conversation_ref)
+ return self._stream
+
@property
def user_graph(self) -> "GraphServiceClient":
"""
@@ -191,8 +197,12 @@ async def send(
self._add_targeted_message_info_entity(activity)
ref = conversation_ref or self.conversation_ref
- res = await self._activity_sender.send(activity, ref)
- return res
+ 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.
diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py
index 3be9917d5..b190f6862 100644
--- a/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py
+++ b/packages/apps/src/microsoft_teams/apps/routing/activity_route_configs.py
@@ -8,6 +8,14 @@
from microsoft_teams.api import (
ActivityBase,
+ AgenticUserDeletedActivity,
+ AgenticUserDisabledActivity,
+ AgenticUserEnabledActivity,
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityUpdatedActivity,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserUndeletedActivity,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
CommandResultActivity,
CommandSendActivity,
ConfigFetchInvokeActivity,
@@ -66,6 +74,14 @@ def _conversation_update_has_event_type(activity: ActivityBase, event_type: str)
)
+def _is_agent_lifecycle(activity: ActivityBase) -> bool:
+ return activity.type == "event" and getattr(activity, "name", None) == "agentLifecycle"
+
+
+def _is_agent_lifecycle_variant(activity: ActivityBase, value_type: str) -> bool:
+ return _is_agent_lifecycle(activity) and getattr(activity, "value_type", None) == value_type
+
+
@dataclass(frozen=True)
class ActivityConfig:
"""Configuration for an activity handler."""
@@ -287,6 +303,70 @@ class ActivityConfig:
and cast(EventActivity, activity).name == "application/vnd.microsoft.meetingParticipantLeave",
output_model=None,
),
+ # Agent 365 agentLifecycle event activities
+ "agent_lifecycle": ActivityConfig(
+ name="agent_lifecycle",
+ method_name="on_agent_lifecycle",
+ input_model="AgentLifecycleEventActivity",
+ selector=_is_agent_lifecycle,
+ output_model=None,
+ ),
+ "agentic_user_identity_created": ActivityConfig(
+ name="agentic_user_identity_created",
+ method_name="on_agentic_user_identity_created",
+ input_model=AgenticUserIdentityCreatedActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserIdentityCreated"),
+ output_model=None,
+ ),
+ "agentic_user_identity_updated": ActivityConfig(
+ name="agentic_user_identity_updated",
+ method_name="on_agentic_user_identity_updated",
+ input_model=AgenticUserIdentityUpdatedActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserIdentityUpdated"),
+ output_model=None,
+ ),
+ "agentic_user_manager_updated": ActivityConfig(
+ name="agentic_user_manager_updated",
+ method_name="on_agentic_user_manager_updated",
+ input_model=AgenticUserManagerUpdatedActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserManagerUpdated"),
+ output_model=None,
+ ),
+ "agentic_user_enabled": ActivityConfig(
+ name="agentic_user_enabled",
+ method_name="on_agentic_user_enabled",
+ input_model=AgenticUserEnabledActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserEnabled"),
+ output_model=None,
+ ),
+ "agentic_user_disabled": ActivityConfig(
+ name="agentic_user_disabled",
+ method_name="on_agentic_user_disabled",
+ input_model=AgenticUserDisabledActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserDisabled"),
+ output_model=None,
+ ),
+ "agentic_user_deleted": ActivityConfig(
+ name="agentic_user_deleted",
+ method_name="on_agentic_user_deleted",
+ input_model=AgenticUserDeletedActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserDeleted"),
+ output_model=None,
+ ),
+ "agentic_user_undeleted": ActivityConfig(
+ name="agentic_user_undeleted",
+ method_name="on_agentic_user_undeleted",
+ input_model=AgenticUserUndeletedActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserUndeleted"),
+ output_model=None,
+ ),
+ "agentic_user_workload_onboarding_updated": ActivityConfig(
+ name="agentic_user_workload_onboarding_updated",
+ method_name="on_agentic_user_workload_onboarding_updated",
+ input_model=AgenticUserWorkloadOnboardingUpdatedActivity,
+ selector=lambda activity: _is_agent_lifecycle_variant(activity, "AgenticUserWorkloadOnboardingUpdated"),
+ output_model=None,
+ ),
# Invoke Activities with specific names and response types
"config.open": ActivityConfig(
name="config.open",
diff --git a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py
index 2636969b2..c0e94d498 100644
--- a/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py
+++ b/packages/apps/src/microsoft_teams/apps/routing/generated_handlers.py
@@ -15,6 +15,15 @@
from microsoft_teams.api.activities import (
Activity,
AdaptiveCardInvokeActivity,
+ AgenticUserDeletedActivity,
+ AgenticUserDisabledActivity,
+ AgenticUserEnabledActivity,
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityUpdatedActivity,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserUndeletedActivity,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
+ AgentLifecycleEventActivity,
CommandResultActivity,
CommandSendActivity,
ConfigFetchInvokeActivity,
@@ -742,6 +751,278 @@ def decorator(
return decorator(handler)
return decorator
+ @overload
+ def on_agent_lifecycle(
+ self, handler: BasicHandler[AgentLifecycleEventActivity]
+ ) -> BasicHandler[AgentLifecycleEventActivity]: ...
+
+ @overload
+ def on_agent_lifecycle(
+ self,
+ ) -> Callable[[BasicHandler[AgentLifecycleEventActivity]], BasicHandler[AgentLifecycleEventActivity]]: ...
+
+ def on_agent_lifecycle(
+ self, handler: Optional[BasicHandler[AgentLifecycleEventActivity]] = None
+ ) -> BasicHandlerUnion[AgentLifecycleEventActivity]:
+ """Register a agent_lifecycle activity handler."""
+
+ def decorator(func: BasicHandler[AgentLifecycleEventActivity]) -> BasicHandler[AgentLifecycleEventActivity]:
+ validate_handler_type(
+ func, AgentLifecycleEventActivity, "on_agent_lifecycle", "AgentLifecycleEventActivity"
+ )
+ config = ACTIVITY_ROUTES["agent_lifecycle"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_identity_created(
+ self, handler: BasicHandler[AgenticUserIdentityCreatedActivity]
+ ) -> BasicHandler[AgenticUserIdentityCreatedActivity]: ...
+
+ @overload
+ def on_agentic_user_identity_created(
+ self,
+ ) -> Callable[
+ [BasicHandler[AgenticUserIdentityCreatedActivity]], BasicHandler[AgenticUserIdentityCreatedActivity]
+ ]: ...
+
+ def on_agentic_user_identity_created(
+ self, handler: Optional[BasicHandler[AgenticUserIdentityCreatedActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserIdentityCreatedActivity]:
+ """Register a agentic_user_identity_created activity handler."""
+
+ def decorator(
+ func: BasicHandler[AgenticUserIdentityCreatedActivity],
+ ) -> BasicHandler[AgenticUserIdentityCreatedActivity]:
+ validate_handler_type(
+ func,
+ AgenticUserIdentityCreatedActivity,
+ "on_agentic_user_identity_created",
+ "AgenticUserIdentityCreatedActivity",
+ )
+ config = ACTIVITY_ROUTES["agentic_user_identity_created"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_identity_updated(
+ self, handler: BasicHandler[AgenticUserIdentityUpdatedActivity]
+ ) -> BasicHandler[AgenticUserIdentityUpdatedActivity]: ...
+
+ @overload
+ def on_agentic_user_identity_updated(
+ self,
+ ) -> Callable[
+ [BasicHandler[AgenticUserIdentityUpdatedActivity]], BasicHandler[AgenticUserIdentityUpdatedActivity]
+ ]: ...
+
+ def on_agentic_user_identity_updated(
+ self, handler: Optional[BasicHandler[AgenticUserIdentityUpdatedActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserIdentityUpdatedActivity]:
+ """Register a agentic_user_identity_updated activity handler."""
+
+ def decorator(
+ func: BasicHandler[AgenticUserIdentityUpdatedActivity],
+ ) -> BasicHandler[AgenticUserIdentityUpdatedActivity]:
+ validate_handler_type(
+ func,
+ AgenticUserIdentityUpdatedActivity,
+ "on_agentic_user_identity_updated",
+ "AgenticUserIdentityUpdatedActivity",
+ )
+ config = ACTIVITY_ROUTES["agentic_user_identity_updated"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_manager_updated(
+ self, handler: BasicHandler[AgenticUserManagerUpdatedActivity]
+ ) -> BasicHandler[AgenticUserManagerUpdatedActivity]: ...
+
+ @overload
+ def on_agentic_user_manager_updated(
+ self,
+ ) -> Callable[
+ [BasicHandler[AgenticUserManagerUpdatedActivity]], BasicHandler[AgenticUserManagerUpdatedActivity]
+ ]: ...
+
+ def on_agentic_user_manager_updated(
+ self, handler: Optional[BasicHandler[AgenticUserManagerUpdatedActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserManagerUpdatedActivity]:
+ """Register a agentic_user_manager_updated activity handler."""
+
+ def decorator(
+ func: BasicHandler[AgenticUserManagerUpdatedActivity],
+ ) -> BasicHandler[AgenticUserManagerUpdatedActivity]:
+ validate_handler_type(
+ func,
+ AgenticUserManagerUpdatedActivity,
+ "on_agentic_user_manager_updated",
+ "AgenticUserManagerUpdatedActivity",
+ )
+ config = ACTIVITY_ROUTES["agentic_user_manager_updated"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_enabled(
+ self, handler: BasicHandler[AgenticUserEnabledActivity]
+ ) -> BasicHandler[AgenticUserEnabledActivity]: ...
+
+ @overload
+ def on_agentic_user_enabled(
+ self,
+ ) -> Callable[[BasicHandler[AgenticUserEnabledActivity]], BasicHandler[AgenticUserEnabledActivity]]: ...
+
+ def on_agentic_user_enabled(
+ self, handler: Optional[BasicHandler[AgenticUserEnabledActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserEnabledActivity]:
+ """Register a agentic_user_enabled activity handler."""
+
+ def decorator(func: BasicHandler[AgenticUserEnabledActivity]) -> BasicHandler[AgenticUserEnabledActivity]:
+ validate_handler_type(
+ func, AgenticUserEnabledActivity, "on_agentic_user_enabled", "AgenticUserEnabledActivity"
+ )
+ config = ACTIVITY_ROUTES["agentic_user_enabled"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_disabled(
+ self, handler: BasicHandler[AgenticUserDisabledActivity]
+ ) -> BasicHandler[AgenticUserDisabledActivity]: ...
+
+ @overload
+ def on_agentic_user_disabled(
+ self,
+ ) -> Callable[[BasicHandler[AgenticUserDisabledActivity]], BasicHandler[AgenticUserDisabledActivity]]: ...
+
+ def on_agentic_user_disabled(
+ self, handler: Optional[BasicHandler[AgenticUserDisabledActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserDisabledActivity]:
+ """Register a agentic_user_disabled activity handler."""
+
+ def decorator(func: BasicHandler[AgenticUserDisabledActivity]) -> BasicHandler[AgenticUserDisabledActivity]:
+ validate_handler_type(
+ func, AgenticUserDisabledActivity, "on_agentic_user_disabled", "AgenticUserDisabledActivity"
+ )
+ config = ACTIVITY_ROUTES["agentic_user_disabled"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_deleted(
+ self, handler: BasicHandler[AgenticUserDeletedActivity]
+ ) -> BasicHandler[AgenticUserDeletedActivity]: ...
+
+ @overload
+ def on_agentic_user_deleted(
+ self,
+ ) -> Callable[[BasicHandler[AgenticUserDeletedActivity]], BasicHandler[AgenticUserDeletedActivity]]: ...
+
+ def on_agentic_user_deleted(
+ self, handler: Optional[BasicHandler[AgenticUserDeletedActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserDeletedActivity]:
+ """Register a agentic_user_deleted activity handler."""
+
+ def decorator(func: BasicHandler[AgenticUserDeletedActivity]) -> BasicHandler[AgenticUserDeletedActivity]:
+ validate_handler_type(
+ func, AgenticUserDeletedActivity, "on_agentic_user_deleted", "AgenticUserDeletedActivity"
+ )
+ config = ACTIVITY_ROUTES["agentic_user_deleted"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_undeleted(
+ self, handler: BasicHandler[AgenticUserUndeletedActivity]
+ ) -> BasicHandler[AgenticUserUndeletedActivity]: ...
+
+ @overload
+ def on_agentic_user_undeleted(
+ self,
+ ) -> Callable[[BasicHandler[AgenticUserUndeletedActivity]], BasicHandler[AgenticUserUndeletedActivity]]: ...
+
+ def on_agentic_user_undeleted(
+ self, handler: Optional[BasicHandler[AgenticUserUndeletedActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserUndeletedActivity]:
+ """Register a agentic_user_undeleted activity handler."""
+
+ def decorator(func: BasicHandler[AgenticUserUndeletedActivity]) -> BasicHandler[AgenticUserUndeletedActivity]:
+ validate_handler_type(
+ func, AgenticUserUndeletedActivity, "on_agentic_user_undeleted", "AgenticUserUndeletedActivity"
+ )
+ config = ACTIVITY_ROUTES["agentic_user_undeleted"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
+ @overload
+ def on_agentic_user_workload_onboarding_updated(
+ self, handler: BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]
+ ) -> BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]: ...
+
+ @overload
+ def on_agentic_user_workload_onboarding_updated(
+ self,
+ ) -> Callable[
+ [BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]],
+ BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity],
+ ]: ...
+
+ def on_agentic_user_workload_onboarding_updated(
+ self, handler: Optional[BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]] = None
+ ) -> BasicHandlerUnion[AgenticUserWorkloadOnboardingUpdatedActivity]:
+ """Register a agentic_user_workload_onboarding_updated activity handler."""
+
+ def decorator(
+ func: BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity],
+ ) -> BasicHandler[AgenticUserWorkloadOnboardingUpdatedActivity]:
+ validate_handler_type(
+ func,
+ AgenticUserWorkloadOnboardingUpdatedActivity,
+ "on_agentic_user_workload_onboarding_updated",
+ "AgenticUserWorkloadOnboardingUpdatedActivity",
+ )
+ config = ACTIVITY_ROUTES["agentic_user_workload_onboarding_updated"]
+ self.router.add_handler(config.selector, func)
+ return func
+
+ if handler is not None:
+ return decorator(handler)
+ return decorator
+
@overload
def on_config_open(
self, handler: InvokeHandler[ConfigFetchInvokeActivity, ConfigInvokeResponse]
@@ -1385,8 +1666,7 @@ def on_suggested_action_submit(
def on_suggested_action_submit(
self,
) -> Callable[
- [VoidInvokeHandler[SuggestedActionSubmitInvokeActivity]],
- VoidInvokeHandler[SuggestedActionSubmitInvokeActivity],
+ [VoidInvokeHandler[SuggestedActionSubmitInvokeActivity]], VoidInvokeHandler[SuggestedActionSubmitInvokeActivity]
]: ...
def on_suggested_action_submit(
diff --git a/packages/apps/src/microsoft_teams/apps/token_manager.py b/packages/apps/src/microsoft_teams/apps/token_manager.py
index e4c3525f1..9da102ece 100644
--- a/packages/apps/src/microsoft_teams/apps/token_manager.py
+++ b/packages/apps/src/microsoft_teams/apps/token_manager.py
@@ -5,11 +5,12 @@
import asyncio
import logging
-from inspect import isawaitable
-from typing import Any, Optional
+from inspect import Parameter, isawaitable, signature
+from typing import Any, Awaitable, Callable, Optional, cast
import requests
from microsoft_teams.api import (
+ AgenticIdentity,
ClientCredentials,
Credentials,
JsonWebToken,
@@ -29,6 +30,8 @@
)
DEFAULT_TENANT_FOR_GRAPH_TOKEN = "common"
+TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"
+AGENT_BOT_API_SCOPE = "https://botapi.skype.com/.default"
logger = logging.getLogger(__name__)
@@ -45,12 +48,29 @@ def __init__(
self._cloud = cloud or PUBLIC
self._confidential_clients_by_tenant: dict[str, ConfidentialClientApplication] = {}
self._federated_identity_clients_by_tenant: dict[str, ConfidentialClientApplication] = {}
+ self._agent_identity_clients_by_tenant_and_app_id: dict[tuple[str, str], ConfidentialClientApplication] = {}
self._managed_identity_client: Optional[ManagedIdentityClient] = None
async def get_bot_token(self) -> Optional[TokenProtocol]:
"""Refresh the bot authentication token."""
+ return await self.get_app_token(self._cloud.bot_scope, default_tenant_id=self._cloud.login_tenant)
+
+ async def get_app_token(
+ self,
+ scope: str,
+ tenant_id: Optional[str] = None,
+ *,
+ default_tenant_id: str | None = None,
+ caller_name: str | None = None,
+ ) -> Optional[TokenProtocol]:
+ """Get an app token for the requested scope."""
+ resolved_tenant_id = self._resolve_tenant_id(tenant_id, default_tenant_id or self._cloud.login_tenant)
+ if resolved_tenant_id is None:
+ raise ValueError("tenant_id is required to get an app token")
return await self._get_token(
- self._cloud.bot_scope, tenant_id=self._resolve_tenant_id(None, self._cloud.login_tenant)
+ scope,
+ tenant_id=resolved_tenant_id,
+ caller_name=caller_name,
)
async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[TokenProtocol]:
@@ -64,10 +84,78 @@ async def get_graph_token(self, tenant_id: Optional[str] = None) -> Optional[Tok
Returns:
The graph token or None if not available
"""
- return await self._get_token(
- self._cloud.graph_scope, tenant_id=self._resolve_tenant_id(tenant_id, DEFAULT_TENANT_FOR_GRAPH_TOKEN)
+ return await self.get_app_token(
+ self._cloud.graph_scope,
+ tenant_id=tenant_id,
+ default_tenant_id=DEFAULT_TENANT_FOR_GRAPH_TOKEN,
)
+ async def get_agentic_token(
+ self,
+ scope: str,
+ agentic_identity: AgenticIdentity,
+ *,
+ caller_name: str | None = None,
+ ) -> Optional[TokenProtocol]:
+ """Get a resource token for an agentic identity acting through its agentic user."""
+ if not agentic_identity.agentic_user_id:
+ raise ValueError("agentic_identity.agentic_user_id is required to get an agentic token")
+ if self._credentials is None:
+ if caller_name:
+ logger.debug(f"No credentials provided for {caller_name}")
+ return None
+
+ tenant_id = self._resolve_tenant_id(agentic_identity.tenant_id, None)
+ if tenant_id is None:
+ raise ValueError("tenant_id is required to get an agentic token")
+
+ credentials = self._credentials
+ if isinstance(credentials, TokenCredentials):
+ return await self._get_token_with_token_provider(credentials, scope, tenant_id, agentic_identity)
+
+ if not isinstance(credentials, ClientCredentials):
+ raise ValueError("Agent user tokens require ClientCredentials")
+ confidential_client = self._get_confidential_client(credentials, tenant_id)
+
+ def get_t1_assertion(_context: dict[str, Any]) -> str:
+ t1_raw: dict[str, Any] = confidential_client.acquire_token_for_client(
+ [TOKEN_EXCHANGE_SCOPE], fmi_path=agentic_identity.agentic_app_id
+ )
+ return self._get_access_token_or_raise(t1_raw, "Agent token exchange step 1 failed")
+
+ # The agent identity app needs its own MSAL client. It uses the Federated Managed
+ # Identity assertion from step 1 as its client assertion for the next exchanges.
+ t2_confidential_client = self._get_agent_identity_client(
+ tenant_id,
+ agentic_identity.agentic_app_id,
+ get_t1_assertion,
+ )
+
+ t2_raw: dict[str, Any] = await asyncio.to_thread(
+ lambda: t2_confidential_client.acquire_token_for_client([TOKEN_EXCHANGE_SCOPE])
+ )
+
+ t2 = self._get_access_token_or_raise(t2_raw, "Agent token exchange step 2 failed")
+
+ t3_raw: dict[str, Any] = await asyncio.to_thread(
+ lambda: t2_confidential_client.acquire_token_by_user_federated_identity_credential(
+ [scope],
+ assertion=t2,
+ user_object_id=agentic_identity.agentic_user_id,
+ username=None,
+ data={"requested_token_use": "on_behalf_of"},
+ )
+ )
+ return self._handle_token_response(t3_raw, caller_name or "get_agentic_token")
+
+ def _get_access_token_or_raise(self, token_res: dict[str, Any], error_prefix: str) -> str:
+ if token_res.get("access_token", None):
+ return token_res["access_token"]
+
+ error_description = token_res.get("error_description") or token_res.get("error") or "Could not acquire token"
+ logger.error(f"{error_prefix}: {error_description}")
+ raise ValueError(f"{error_prefix}: {error_description}")
+
async def _get_token(
self, scope: str, tenant_id: str, *, caller_name: str | None = None
) -> Optional[TokenProtocol]:
@@ -160,9 +248,10 @@ async def _get_token_with_token_provider(
credentials: TokenCredentials,
scope: str,
tenant_id: str,
+ agentic_identity: AgenticIdentity | None = None,
) -> TokenProtocol:
"""Get token using custom token provider function."""
- token = credentials.token(scope, tenant_id)
+ token = self._call_token_provider(credentials, scope, tenant_id, agentic_identity)
if isawaitable(token):
access_token = await token
@@ -171,6 +260,45 @@ async def _get_token_with_token_provider(
return JsonWebToken(access_token)
+ def _call_token_provider(
+ self,
+ credentials: TokenCredentials,
+ scope: str,
+ tenant_id: str,
+ agentic_identity: AgenticIdentity | None = None,
+ ) -> str | Awaitable[str]:
+ token_provider = cast(Any, credentials.token)
+ try:
+ parameters = list(signature(token_provider).parameters.values())
+ except (TypeError, ValueError) as error:
+ if agentic_identity is not None:
+ raise ValueError("Token provider must accept agentic_identity to mint agentic tokens") from error
+ return cast(str | Awaitable[str], token_provider(scope, tenant_id))
+
+ accepts_agentic_identity = any(
+ parameter.kind == Parameter.VAR_KEYWORD or parameter.name == "agentic_identity" for parameter in parameters
+ )
+ if accepts_agentic_identity:
+ return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity=agentic_identity))
+
+ positional_parameters = [
+ parameter
+ for parameter in parameters
+ if parameter.kind in (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD)
+ ]
+ required_positional_parameters = [
+ parameter for parameter in positional_parameters if parameter.default is Parameter.empty
+ ]
+ if len(positional_parameters) >= 3 and (
+ agentic_identity is not None or len(required_positional_parameters) >= 3
+ ):
+ return cast(str | Awaitable[str], token_provider(scope, tenant_id, agentic_identity))
+
+ if agentic_identity is not None:
+ raise ValueError("Token provider must accept agentic_identity to mint agentic tokens")
+
+ return cast(str | Awaitable[str], token_provider(scope, tenant_id))
+
def _handle_token_response(self, token_res: dict[str, Any], error_prefix: str = "") -> TokenProtocol:
"""Handle token response from MSAL client."""
if token_res.get("access_token", None):
@@ -220,6 +348,24 @@ def _get_federated_identity_client(
self._federated_identity_clients_by_tenant[tenant_id] = client
return client
+ def _get_agent_identity_client(
+ self,
+ tenant_id: str,
+ agentic_app_id: str,
+ client_assertion: Callable[[dict[str, Any]], str],
+ ) -> ConfidentialClientApplication:
+ cached_client = self._agent_identity_clients_by_tenant_and_app_id.get((tenant_id, agentic_app_id))
+ if cached_client:
+ return cached_client
+
+ client: ConfidentialClientApplication = ConfidentialClientApplication(
+ agentic_app_id,
+ client_credential={"client_assertion": client_assertion},
+ authority=f"{self._cloud.login_endpoint}/{tenant_id}",
+ )
+ self._agent_identity_clients_by_tenant_and_app_id[(tenant_id, agentic_app_id)] = client
+ return client
+
def _get_managed_identity_client(
self, credentials: ManagedIdentityCredentials | FederatedIdentityCredentials
) -> ManagedIdentityClient:
@@ -247,5 +393,5 @@ def _get_managed_identity_client(
)
return self._managed_identity_client
- def _resolve_tenant_id(self, tenant_id: str | None, default_tenant_id: str):
- return tenant_id or (self._credentials.tenant_id if self._credentials else False) or default_tenant_id
+ def _resolve_tenant_id(self, tenant_id: str | None, default_tenant_id: str | None) -> str | None:
+ return tenant_id or (self._credentials.tenant_id if self._credentials else None) or default_tenant_id
diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py
index 606e5f24f..db764e508 100644
--- a/packages/apps/tests/test_activity_context.py
+++ b/packages/apps/tests/test_activity_context.py
@@ -39,6 +39,50 @@ def _create_activity_context(
)
)
mock_activity_sender.create_stream = MagicMock(return_value=MagicMock())
+ activities = MagicMock()
+ activities.create = mock_activity_sender.send
+ activities.update = AsyncMock(
+ return_value=SentActivity(
+ id="updated-activity-id",
+ activity_params=MessageActivityInput(text="updated"),
+ )
+ )
+ activities.create_targeted = AsyncMock(
+ return_value=SentActivity(
+ id="targeted-activity-id",
+ activity_params=MessageActivityInput(text="targeted"),
+ )
+ )
+ activities.update_targeted = AsyncMock(
+ return_value=SentActivity(
+ id="updated-targeted-activity-id",
+ activity_params=MessageActivityInput(text="updated targeted"),
+ )
+ )
+ api = MagicMock()
+ api.conversations.activities.return_value = activities
+ api.clone.return_value = api
+
+ async def create_activity(conversation_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.create(activity)
+
+ async def update_activity(conversation_id: str, activity_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.update(activity_id, activity)
+
+ async def create_targeted_activity(conversation_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.create_targeted(activity)
+
+ async def update_targeted_activity(conversation_id: str, activity_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.update_targeted(activity_id, activity)
+
+ api.conversations.create_activity = AsyncMock(side_effect=create_activity)
+ api.conversations.update_activity = AsyncMock(side_effect=update_activity)
+ api.conversations.create_targeted_activity = AsyncMock(side_effect=create_targeted_activity)
+ api.conversations.update_targeted_activity = AsyncMock(side_effect=update_targeted_activity)
conversation_ref = ConversationReference(
bot=Account(id="bot-id", name="Test Bot"),
@@ -51,12 +95,11 @@ def _create_activity_context(
activity=mock_activity,
app_id="test-app-id",
storage=MagicMock(),
- api=MagicMock(),
+ api=api,
user_token=user_token,
conversation_ref=conversation_ref,
is_signed_in=is_signed_in,
connection_name="test-connection",
- activity_sender=mock_activity_sender,
app_token=MagicMock(),
cloud=PUBLIC,
)
@@ -88,16 +131,21 @@ async def test_defaults_send_to_targeted_when_inbound_message_is_targeted(self)
await ctx.send("Secret message")
- mock_sender.send.assert_called_once()
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ ctx.api.conversations.activities.return_value.create_targeted.assert_called_once()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
assert isinstance(sent_activity, MessageActivityInput)
assert sent_activity.text == "Secret message"
+ assert sent_activity.from_ == ctx.conversation_ref.bot
+ assert sent_activity.conversation == ctx.conversation_ref.conversation
assert sent_activity.recipient is not None
assert sent_activity.recipient.id == incoming_sender.id
assert sent_activity.recipient.name == incoming_sender.name
assert sent_activity.recipient.is_targeted is True
assert sent_activity.entities is not None
assert any(isinstance(entity, TargetedMessageInfoEntity) for entity in sent_activity.entities)
+ ctx.api.conversations.activities.return_value.create_targeted.assert_called_once()
+ ctx.api.conversations.activities.return_value.create.assert_not_called()
@pytest.mark.asyncio
async def test_reply_defaults_to_targeted_when_inbound_message_is_targeted(self) -> None:
@@ -107,8 +155,9 @@ async def test_reply_defaults_to_targeted_when_inbound_message_is_targeted(self)
await ctx.reply("Private reply")
- mock_sender.send.assert_called_once()
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ ctx.api.conversations.activities.return_value.create_targeted.assert_called_once()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
assert isinstance(sent_activity, MessageActivityInput)
assert sent_activity.reply_to_id is None
assert sent_activity.recipient is not None
@@ -151,8 +200,7 @@ async def test_different_conversation_does_not_default_to_targeted(self) -> None
mock_sender.send.assert_called_once()
sent_activity = mock_sender.send.call_args[0][0]
- sent_ref = mock_sender.send.call_args[0][1]
- assert sent_ref == other_ref
+ ctx.api.conversations.activities.assert_called_once_with("other-conversation")
assert sent_activity.recipient is None
assert sent_activity.entities is None
@@ -165,8 +213,9 @@ async def test_explicit_targeted_send_from_public_inbound_does_not_add_targeted_
await ctx.send(activity)
- mock_sender.send.assert_called_once()
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ ctx.api.conversations.activities.return_value.create_targeted.assert_called_once()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
assert sent_activity.recipient is not None
assert sent_activity.recipient.is_targeted is True
assert sent_activity.entities is None
@@ -183,8 +232,9 @@ async def test_targeted_outbound_strips_quoted_reply_metadata(self) -> None:
await ctx.send(activity)
- mock_sender.send.assert_called_once()
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ ctx.api.conversations.activities.return_value.create_targeted.assert_called_once()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
assert sent_activity.text == "Secret"
assert sent_activity.entities is not None
assert all(not isinstance(entity, QuotedReplyEntity) for entity in sent_activity.entities)
@@ -207,10 +257,10 @@ async def test_targeted_message_with_explicit_recipient(self) -> None:
await ctx.send(activity)
# Verify send was called
- mock_sender.send.assert_called_once()
+ ctx.api.conversations.activities.return_value.create_targeted.assert_called_once()
+ mock_sender.send.assert_not_called()
- # Get the activity that was passed to send
- sent_activity = mock_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
# Verify recipient was preserved
assert sent_activity.recipient is not None
@@ -232,11 +282,8 @@ async def test_targeted_update_preserves_recipient(self) -> None:
await ctx.send(activity)
- # Verify send was called
- mock_sender.send.assert_called_once()
-
- # Get the activity that was passed to send
- sent_activity = mock_sender.send.call_args[0][0]
+ ctx.api.conversations.activities.return_value.update_targeted.assert_called_once()
+ sent_activity = ctx.api.conversations.activities.return_value.update_targeted.call_args.args[1]
# Verify recipient was preserved
assert sent_activity.recipient is not None
@@ -244,6 +291,31 @@ async def test_targeted_update_preserves_recipient(self) -> None:
assert sent_activity.recipient.name == incoming_sender.name
assert sent_activity.recipient.is_targeted is True
+ @pytest.mark.asyncio
+ async def test_send_existing_activity_updates(self) -> None:
+ incoming_sender = Account(id="user-123", name="Test User")
+ ctx, mock_sender = self._create_activity_context(from_account=incoming_sender)
+ activity = MessageActivityInput(text="Updated message")
+ activity.id = "existing-msg-id"
+
+ await ctx.send(activity)
+
+ ctx.api.conversations.activities.return_value.update.assert_called_once_with(
+ "existing-msg-id",
+ activity,
+ )
+ mock_sender.send.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_targeted_send_in_personal_chat_raises(self) -> None:
+ incoming_sender = Account(id="user-123", name="Test User")
+ ctx, _ = self._create_activity_context(from_account=incoming_sender)
+ ctx.conversation_ref.conversation.conversation_type = "personal"
+ activity = MessageActivityInput(text="Nope").with_recipient(incoming_sender, is_targeted=True)
+
+ with pytest.raises(ValueError, match="Targeted messages are not supported in 1:1"):
+ await ctx.send(activity)
+
@pytest.mark.asyncio
async def test_targeted_with_different_recipient(self) -> None:
"""
@@ -261,10 +333,9 @@ async def test_targeted_with_different_recipient(self) -> None:
await ctx.send(activity)
# Verify send was called
- mock_sender.send.assert_called_once()
-
- # Get the activity that was passed to send
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ ctx.api.conversations.activities.return_value.create_targeted.assert_called_once()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
# Verify recipient was preserved
assert sent_activity.recipient is not None
@@ -328,6 +399,33 @@ async def test_send_with_adaptive_card(self) -> None:
assert len(sent_activity.attachments) > 0
assert isinstance(result, SentActivity)
+ @pytest.mark.asyncio
+ async def test_send_passes_inbound_agentic_identity(self) -> None:
+ """Sending from an Agent ID activity uses the inbound agentic identity."""
+ recipient = Account(
+ id="bot-id",
+ name="Test Bot",
+ agentic_app_id="agentic-app-id",
+ agentic_user_id="agentic-user-id",
+ tenant_id="tenant-id",
+ )
+ activity = MessageActivity(
+ id="incoming-activity-id",
+ text="Incoming message",
+ from_=Account(id="user-id", name="Test User"),
+ recipient=recipient,
+ conversation=ConversationAccount(id="test-conversation"),
+ )
+ ctx, mock_sender = _create_activity_context(activity=activity)
+
+ await ctx.send("Hello")
+
+ mock_sender.send.assert_called_once()
+ ctx.api.clone.assert_called_once_with(
+ service_url=ctx.conversation_ref.service_url,
+ agentic_identity=recipient.agentic_identity,
+ )
+
class TestActivityContextReply:
"""Tests for ActivityContext.reply()."""
@@ -372,6 +470,33 @@ async def test_reply_with_activity_params(self) -> None:
assert isinstance(sent_activity.entities[0], QuotedReplyEntity)
assert sent_activity.entities[0].quoted_reply.message_id == "evt-id-999"
+ @pytest.mark.asyncio
+ async def test_reply_passes_inbound_agentic_identity(self) -> None:
+ """Replying to an Agent ID activity uses the inbound agentic identity."""
+ recipient = Account(
+ id="bot-id",
+ name="Test Bot",
+ agentic_app_id="agentic-app-id",
+ agentic_user_id="agentic-user-id",
+ tenant_id="tenant-id",
+ )
+ activity = MessageActivity(
+ id="incoming-activity-id",
+ text="Incoming message",
+ from_=Account(id="user-id", name="Test User"),
+ recipient=recipient,
+ conversation=ConversationAccount(id="test-conversation"),
+ )
+ ctx, mock_sender = _create_activity_context(activity=activity)
+
+ await ctx.reply("Hello")
+
+ mock_sender.send.assert_called_once()
+ ctx.api.clone.assert_called_once_with(
+ service_url=ctx.conversation_ref.service_url,
+ agentic_identity=recipient.agentic_identity,
+ )
+
class TestActivityContextUserGraph:
"""Tests for ActivityContext.user_graph property."""
@@ -504,17 +629,20 @@ async def test_sign_in_returns_token_when_already_available(self) -> None:
@pytest.mark.asyncio
async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None:
"""sign_in falls through to OAuth card flow when token API fails, returns None."""
- mock_activity = MagicMock()
- mock_activity.channel_id = "msteams"
- mock_activity.from_ = Account(id="user-001")
- mock_activity.conversation.is_group = False
+ mock_activity = MessageActivity(
+ id="activity-id",
+ channel_id="msteams",
+ from_=Account(id="user-001"),
+ recipient=Account(id="bot-id"),
+ conversation=ConversationAccount(id="test-conversation", is_group=False),
+ )
ctx, mock_sender = _create_activity_context(activity=mock_activity)
ctx.api.users.get_token = AsyncMock(side_effect=Exception("no token"))
resource_response = MagicMock()
- resource_response.token_exchange_resource = MagicMock()
- resource_response.token_post_resource = MagicMock()
+ resource_response.token_exchange_resource = None
+ resource_response.token_post_resource = None
resource_response.sign_in_link = "https://login.example.com"
ctx.api._bots.sign_in.get_resource = AsyncMock(return_value=resource_response)
@@ -525,10 +653,6 @@ async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None:
"microsoft_teams.apps.routing.activity_context.TokenExchangeState",
return_value=token_state,
),
- patch("microsoft_teams.apps.routing.activity_context.card_attachment"),
- patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"),
- patch("microsoft_teams.apps.routing.activity_context.OAuthCard"),
- patch("microsoft_teams.apps.routing.activity_context.CardAction"),
patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"),
):
result = await ctx.sign_in()
@@ -540,11 +664,13 @@ async def test_sign_in_sends_oauth_card_when_no_existing_token(self) -> None:
@pytest.mark.asyncio
async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> None:
"""For group conversations, sign_in creates a 1:1 conversation before sending the OAuth card."""
- mock_activity = MagicMock()
- mock_activity.channel_id = "msteams"
- mock_activity.from_ = Account(id="user-001")
- mock_activity.conversation.is_group = True
- mock_activity.conversation.tenant_id = "tenant-001"
+ mock_activity = MessageActivity(
+ id="activity-id",
+ channel_id="msteams",
+ from_=Account(id="user-001"),
+ recipient=Account(id="bot-id"),
+ conversation=ConversationAccount(id="test-conversation", is_group=True, tenant_id="tenant-001"),
+ )
ctx, mock_sender = _create_activity_context(activity=mock_activity)
ctx.api.users.get_token = AsyncMock(side_effect=Exception("no token"))
@@ -554,8 +680,8 @@ async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> N
ctx.api.conversations.create = AsyncMock(return_value=one_on_one)
resource_response = MagicMock()
- resource_response.token_exchange_resource = MagicMock()
- resource_response.token_post_resource = MagicMock()
+ resource_response.token_exchange_resource = None
+ resource_response.token_post_resource = None
resource_response.sign_in_link = "https://login.example.com"
ctx.api._bots.sign_in.get_resource = AsyncMock(return_value=resource_response)
@@ -567,10 +693,6 @@ async def test_sign_in_creates_one_on_one_conversation_for_group_chat(self) -> N
return_value=token_state,
),
patch("microsoft_teams.apps.routing.activity_context.CreateConversationParams"),
- patch("microsoft_teams.apps.routing.activity_context.card_attachment"),
- patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"),
- patch("microsoft_teams.apps.routing.activity_context.OAuthCard"),
- patch("microsoft_teams.apps.routing.activity_context.CardAction"),
patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"),
):
result = await ctx.sign_in()
@@ -585,17 +707,20 @@ async def test_sign_in_uses_signin_options_connection_name_override(self) -> Non
"""sign_in respects SignInOptions.connection_name override when fetching the existing token."""
from microsoft_teams.apps.routing.activity_context import SignInOptions
- mock_activity = MagicMock()
- mock_activity.channel_id = "msteams"
- mock_activity.from_ = Account(id="user-001")
- mock_activity.conversation.is_group = False
+ mock_activity = MessageActivity(
+ id="activity-id",
+ channel_id="msteams",
+ from_=Account(id="user-001"),
+ recipient=Account(id="bot-id"),
+ conversation=ConversationAccount(id="test-conversation", is_group=False),
+ )
ctx, _ = _create_activity_context(activity=mock_activity)
ctx.api.users.get_token = AsyncMock(side_effect=Exception("no token"))
resource_response = MagicMock()
- resource_response.token_exchange_resource = MagicMock()
- resource_response.token_post_resource = MagicMock()
+ resource_response.token_exchange_resource = None
+ resource_response.token_post_resource = None
resource_response.sign_in_link = "https://login.example.com"
ctx.api._bots.sign_in.get_resource = AsyncMock(return_value=resource_response)
@@ -612,10 +737,6 @@ async def test_sign_in_uses_signin_options_connection_name_override(self) -> Non
"microsoft_teams.apps.routing.activity_context.TokenExchangeState",
return_value=token_state,
),
- patch("microsoft_teams.apps.routing.activity_context.card_attachment"),
- patch("microsoft_teams.apps.routing.activity_context.OAuthCardAttachment"),
- patch("microsoft_teams.apps.routing.activity_context.OAuthCard"),
- patch("microsoft_teams.apps.routing.activity_context.CardAction"),
patch("microsoft_teams.apps.routing.activity_context.GetBotSignInResourceParams"),
):
result = await ctx.sign_in(options=custom_options)
@@ -692,7 +813,8 @@ async def test_send_auto_adds_targeted_message_info_entity(self) -> None:
await ctx.send("Here is your agenda")
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
assert sent_activity.entities is not None
assert len(sent_activity.entities) == 1
entity = sent_activity.entities[0]
@@ -720,7 +842,8 @@ async def test_send_does_not_duplicate_entity_if_already_present(self) -> None:
msg = MessageActivityInput(text="Reply").add_entity(TargetedMessageInfoEntity(message_id="custom-id"))
await ctx.send(msg)
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
assert sent_activity.entities is not None
assert len(sent_activity.entities) == 1
assert sent_activity.entities[0].message_id == "custom-id"
@@ -735,7 +858,8 @@ async def test_reply_auto_adds_targeted_message_info_entity(self) -> None:
await ctx.reply("Reply with prompt preview")
- sent_activity = mock_sender.send.call_args[0][0]
+ mock_sender.send.assert_not_called()
+ sent_activity = ctx.api.conversations.activities.return_value.create_targeted.call_args.args[0]
assert sent_activity.entities is not None
targeted_entities = [e for e in sent_activity.entities if isinstance(e, TargetedMessageInfoEntity)]
assert len(targeted_entities) == 1
diff --git a/packages/apps/tests/test_activity_sender.py b/packages/apps/tests/test_activity_sender.py
deleted file mode 100644
index 5d72de105..000000000
--- a/packages/apps/tests/test_activity_sender.py
+++ /dev/null
@@ -1,197 +0,0 @@
-"""
-Copyright (c) Microsoft Corporation. All rights reserved.
-Licensed under the MIT License.
-"""
-# pyright: basic
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-from microsoft_teams.api import (
- Account,
- ConversationAccount,
- ConversationReference,
- MessageActivityInput,
- SentActivity,
-)
-from microsoft_teams.apps.activity_sender import ActivitySender
-
-
-class TestActivitySender:
- """Test cases for ActivitySender."""
-
- @pytest.fixture
- def sender(self):
- """Create an ActivitySender for testing."""
- mock_client = MagicMock()
- return ActivitySender(client=mock_client)
-
- @pytest.fixture
- def conversation_ref(self):
- """Create a conversation reference for testing."""
- return ConversationReference(
- bot=Account(id="bot-123", name="Test Bot", role="bot"),
- conversation=ConversationAccount(id="conv-456", conversation_type="personal"),
- channel_id="msteams",
- service_url="https://test.service.url",
- )
-
- @pytest.fixture
- def group_conversation_ref(self):
- """Create a group chat conversation reference for testing."""
- return ConversationReference(
- bot=Account(id="bot-123", name="Test Bot", role="bot"),
- conversation=ConversationAccount(id="conv-789", conversation_type="groupChat"),
- channel_id="msteams",
- service_url="https://test.service.url",
- )
-
- def _create_sent_activity(self, activity, activity_id="msg-123"):
- """Helper to create a proper SentActivity mock."""
- return SentActivity(id=activity_id, activity_params=activity)
-
- @pytest.mark.asyncio
- async def test_send_new_message_calls_create(self, sender, conversation_ref):
- """Test that new messages (no id) use the create_activity method."""
- activity = MessageActivityInput(text="Hello")
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.create_activity = AsyncMock(return_value=self._create_sent_activity(activity))
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, conversation_ref)
-
- mock_api.conversations.create_activity.assert_called_once_with("conv-456", activity)
-
- @pytest.mark.asyncio
- async def test_send_existing_message_calls_update(self, sender, conversation_ref):
- """Test that messages with an id use the update_activity method."""
- activity = MessageActivityInput(text="Updated message")
- activity.id = "existing-msg-id"
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.update_activity = AsyncMock(
- return_value=self._create_sent_activity(activity, "existing-msg-id")
- )
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, conversation_ref)
-
- mock_api.conversations.update_activity.assert_called_once_with("conv-456", "existing-msg-id", activity)
-
- @pytest.mark.asyncio
- async def test_send_sets_from_and_conversation(self, sender, conversation_ref):
- """Test that send merges activity with conversation reference."""
- activity = MessageActivityInput(text="Hello")
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.create_activity = AsyncMock(return_value=self._create_sent_activity(activity))
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, conversation_ref)
-
- assert activity.from_ == conversation_ref.bot
- assert activity.conversation == conversation_ref.conversation
-
- @pytest.mark.asyncio
- async def test_send_targeted_message_calls_create_targeted(self, sender, group_conversation_ref):
- """Test that targeted messages use the create_targeted_activity method."""
- recipient = Account(id="user-123", name="Test User", role="user")
- activity = MessageActivityInput(text="Hello").with_recipient(recipient, is_targeted=True)
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.create_targeted_activity = AsyncMock(
- return_value=self._create_sent_activity(activity)
- )
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, group_conversation_ref)
-
- mock_api.conversations.create_targeted_activity.assert_called_once_with("conv-789", activity)
- mock_api.conversations.create_activity.assert_not_called()
-
- @pytest.mark.asyncio
- async def test_send_non_targeted_message_does_not_call_create_targeted(self, sender, conversation_ref):
- """Test that non-targeted messages use the regular create_activity method."""
- activity = MessageActivityInput(text="Hello")
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.create_activity = AsyncMock(return_value=self._create_sent_activity(activity))
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, conversation_ref)
-
- mock_api.conversations.create_activity.assert_called_once_with("conv-456", activity)
- mock_api.conversations.create_targeted_activity.assert_not_called()
-
- @pytest.mark.asyncio
- async def test_update_targeted_message_calls_update_targeted(self, sender, group_conversation_ref):
- """Test that targeted message updates use the update_targeted_activity method."""
- activity = MessageActivityInput(text="Updated targeted message")
- activity.recipient = Account(id="user-123", name="Test User", role="user", is_targeted=True)
- activity.id = "existing-msg-id"
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.update_targeted_activity = AsyncMock(
- return_value=self._create_sent_activity(activity, "existing-msg-id")
- )
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, group_conversation_ref)
-
- mock_api.conversations.update_targeted_activity.assert_called_once_with(
- "conv-789", "existing-msg-id", activity
- )
- mock_api.conversations.update_activity.assert_not_called()
-
- @pytest.mark.asyncio
- async def test_update_non_targeted_message_calls_update(self, sender, conversation_ref):
- """Test that non-targeted message updates use the regular update_activity method."""
- activity = MessageActivityInput(text="Updated message")
- activity.id = "existing-msg-id"
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.update_activity = AsyncMock(
- return_value=self._create_sent_activity(activity, "existing-msg-id")
- )
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, conversation_ref)
-
- mock_api.conversations.update_activity.assert_called_once_with("conv-456", "existing-msg-id", activity)
- mock_api.conversations.update_targeted_activity.assert_not_called()
-
- @pytest.mark.asyncio
- async def test_send_targeted_in_personal_chat_raises(self, sender, conversation_ref):
- """Test that sending a targeted message in a personal (1:1) chat raises ValueError."""
- activity = MessageActivityInput(text="Hello").with_recipient(
- Account(id="user-1", name="User"), is_targeted=True
- )
-
- with pytest.raises(ValueError, match="Targeted messages are not supported in 1:1"):
- await sender.send(activity, conversation_ref)
-
- @pytest.mark.asyncio
- async def test_send_targeted_in_group_chat_succeeds(self, sender, group_conversation_ref):
- """Test that sending a targeted message in a group chat proceeds normally."""
- activity = MessageActivityInput(text="Hello").with_recipient(
- Account(id="user-1", name="User"), is_targeted=True
- )
-
- with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
- mock_api = MagicMock()
- mock_api.conversations.create_targeted_activity = AsyncMock(
- return_value=self._create_sent_activity(activity)
- )
- mock_api_client.return_value = mock_api
-
- await sender.send(activity, group_conversation_ref)
-
- mock_api.conversations.create_targeted_activity.assert_called_once()
diff --git a/packages/apps/tests/test_agent_lifecycle_routes.py b/packages/apps/tests/test_agent_lifecycle_routes.py
new file mode 100644
index 000000000..f21cc1fa2
--- /dev/null
+++ b/packages/apps/tests/test_agent_lifecycle_routes.py
@@ -0,0 +1,86 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+import pytest
+from microsoft_teams.api import Account, ConversationAccount
+from microsoft_teams.api.activities.event.agent_lifecycle import (
+ AgenticUserEnabledActivity,
+ AgenticUserEnabledValue,
+ AgenticUserIdentityCreatedActivity,
+ AgenticUserIdentityCreatedValue,
+ AgenticUserManagerUpdatedActivity,
+ AgenticUserManagerUpdatedValue,
+ AgentLifecycleManagerRef,
+)
+from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES
+
+
+def _identity_created() -> AgenticUserIdentityCreatedActivity:
+ return AgenticUserIdentityCreatedActivity(
+ id="lifecycle-1",
+ channel_id="agents",
+ from_=Account(id="system", name="System"),
+ conversation=ConversationAccount(id="conversation-1"),
+ recipient=Account(id="agentic-user-1"),
+ value=AgenticUserIdentityCreatedValue(agentic_user_id="agentic-user-1"),
+ )
+
+
+def _enabled() -> AgenticUserEnabledActivity:
+ return AgenticUserEnabledActivity(
+ id="lifecycle-2",
+ channel_id="agents",
+ from_=Account(id="system", name="System"),
+ conversation=ConversationAccount(id="conversation-1"),
+ recipient=Account(id="agentic-user-1"),
+ value=AgenticUserEnabledValue(agentic_user_id="agentic-user-1", version=6),
+ )
+
+
+def _manager_updated() -> AgenticUserManagerUpdatedActivity:
+ return AgenticUserManagerUpdatedActivity(
+ id="lifecycle-3",
+ channel_id="agents",
+ from_=Account(id="system", name="System"),
+ conversation=ConversationAccount(id="conversation-1"),
+ recipient=Account(id="agentic-user-1"),
+ value=AgenticUserManagerUpdatedValue(
+ agentic_user_id="agentic-user-1", manager=AgentLifecycleManagerRef(manager_id="manager-1")
+ ),
+ )
+
+
+def test_general_agent_lifecycle_route_matches_every_variant() -> None:
+ selector = ACTIVITY_ROUTES["agent_lifecycle"].selector
+
+ assert selector(_identity_created())
+ assert selector(_enabled())
+ assert selector(_manager_updated())
+
+
+@pytest.mark.parametrize(
+ "route_key,activity_factory",
+ [
+ ("agentic_user_identity_created", _identity_created),
+ ("agentic_user_enabled", _enabled),
+ ("agentic_user_manager_updated", _manager_updated),
+ ],
+)
+def test_variant_route_matches_only_its_own_variant(route_key, activity_factory) -> None:
+ activity = activity_factory()
+
+ assert ACTIVITY_ROUTES[route_key].selector(activity)
+
+ other_keys = [
+ key
+ for key in (
+ "agentic_user_identity_created",
+ "agentic_user_enabled",
+ "agentic_user_manager_updated",
+ )
+ if key != route_key
+ ]
+ for other in other_keys:
+ assert not ACTIVITY_ROUTES[other].selector(activity)
diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py
index 57585c4f9..54652cdcd 100644
--- a/packages/apps/tests/test_app.py
+++ b/packages/apps/tests/test_app.py
@@ -8,13 +8,14 @@
import importlib.metadata
import os
import re
-from typing import Optional
+from typing import Any, Optional
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from microsoft_teams.api import (
Account,
+ AgenticIdentity,
ConversationAccount,
FederatedIdentityCredentials,
InvokeActivity,
@@ -69,6 +70,29 @@ def __str__(self) -> str:
return "FakeToken"
+def _wire_flat_activity_methods(api: Any, activities: MagicMock) -> None:
+ async def create_activity(conversation_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.create(activity)
+
+ async def update_activity(conversation_id: str, activity_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.update(activity_id, activity)
+
+ async def create_targeted_activity(conversation_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.create_targeted(activity)
+
+ async def update_targeted_activity(conversation_id: str, activity_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.update_targeted(activity_id, activity)
+
+ api.conversations.create_activity = AsyncMock(side_effect=create_activity)
+ api.conversations.update_activity = AsyncMock(side_effect=update_activity)
+ api.conversations.create_targeted_activity = AsyncMock(side_effect=create_targeted_activity)
+ api.conversations.update_targeted_activity = AsyncMock(side_effect=update_targeted_activity)
+
+
class TestApp:
"""Test cases for App class public interface."""
@@ -831,9 +855,17 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st
)
app = App(**options)
app._initialized = True
- app.activity_sender.send = AsyncMock(
+ create = AsyncMock(
return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent"))
)
+ activities = MagicMock()
+ activities.create = create
+ activities.create_targeted = AsyncMock(
+ return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent"))
+ )
+ app.api.conversations.activities = MagicMock(return_value=activities)
+ _wire_flat_activity_methods(app.api, activities)
+ app.api.clone = MagicMock(return_value=app.api)
# Create a targeted message with explicit recipient
recipient = Account(id="user-456", name="Test User", role="user")
@@ -842,8 +874,90 @@ async def test_proactive_targeted_with_explicit_recipient_succeeds(self, mock_st
# Should not raise - explicit recipient provided
result = await app.send("conv-123", activity)
- app.activity_sender.send.assert_called_once()
+ activities.create_targeted.assert_called_once()
+ create.assert_not_called()
assert result.id == "sent-activity-id"
+ sent_activity = activities.create_targeted.call_args.args[0]
+ assert sent_activity.from_.id == "test-client-id"
+ assert sent_activity.conversation.id == "conv-123"
+
+ @pytest.mark.asyncio
+ async def test_send_passes_agentic_identity_with_api_service_url(self, mock_storage) -> None:
+ options = AppOptions(storage=mock_storage, client_id="test-client-id", client_secret="test-secret")
+ app = App(**options)
+ app._initialized = True
+ create = AsyncMock(
+ return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent"))
+ )
+ activities = MagicMock()
+ activities.create = create
+ app.api.conversations.activities = MagicMock(return_value=activities)
+ _wire_flat_activity_methods(app.api, activities)
+ app.api.clone = MagicMock(return_value=app.api)
+ agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+
+ result = await app.send(
+ "conv-123",
+ "Hello",
+ agentic_identity=agentic_identity,
+ )
+
+ app.api.conversations.activities.assert_called_once_with("conv-123")
+ app.api.clone.assert_called_once_with(
+ service_url=app.api.service_url,
+ agentic_identity=agentic_identity,
+ )
+ create.assert_called_once()
+ activity = create.call_args.args[0]
+ assert isinstance(activity, MessageActivityInput)
+ assert activity.text == "Hello"
+ assert create.call_args.kwargs == {}
+ assert result.id == "sent-activity-id"
+
+ @pytest.mark.asyncio
+ async def test_send_uses_existing_api_when_agentic_identity_is_none(self, mock_storage) -> None:
+ options = AppOptions(storage=mock_storage, client_id="test-client-id", client_secret="test-secret")
+ app = App(**options)
+ app._initialized = True
+ activities = MagicMock()
+ activities.create = AsyncMock(
+ return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent"))
+ )
+ app.api.conversations.activities = MagicMock(return_value=activities)
+ _wire_flat_activity_methods(app.api, activities)
+ app.api.clone = MagicMock(return_value=app.api)
+
+ await app.send("conv-123", "Hello", agentic_identity=None)
+
+ app.api.clone.assert_not_called()
+
+ def test_get_agentic_identity_preserves_explicit_blueprint_id(self, mock_storage) -> None:
+ """An explicitly provided agentic_app_blueprint_id should be preserved."""
+ options = AppOptions(storage=mock_storage, client_id="test-client-id", client_secret="test-secret")
+ app = App(**options)
+
+ identity = app.get_agentic_identity(
+ "agentic-app-id",
+ "agentic-user-id",
+ tenant_id="tenant-id",
+ agentic_app_blueprint_id="explicit-blueprint-id",
+ )
+
+ assert identity.agentic_app_blueprint_id == "explicit-blueprint-id"
+
+ def test_get_agentic_identity_defaults_blueprint_id_to_client_id(self, mock_storage) -> None:
+ """When agentic_app_blueprint_id is omitted, it should default to the app's client id."""
+ options = AppOptions(storage=mock_storage, client_id="test-client-id", client_secret="test-secret")
+ app = App(**options)
+
+ identity = app.get_agentic_identity(
+ "agentic-app-id",
+ "agentic-user-id",
+ tenant_id="tenant-id",
+ )
+
+ assert identity.agentic_app_blueprint_id == app.id
+ assert identity.agentic_app_blueprint_id == "test-client-id"
class TestAppInitialize:
@@ -857,9 +971,15 @@ async def test_initialize_enables_send(self):
client_secret="test-secret",
dangerously_allow_unauthenticated_requests=True,
)
- app.activity_sender.send = AsyncMock(
- return_value=SentActivity(id="msg-1", activity_params=MessageActivityInput(text="hi"))
+ create = AsyncMock(return_value=SentActivity(id="msg-1", activity_params=MessageActivityInput(text="hi")))
+ activities = MagicMock()
+ activities.create = create
+ activities.update = AsyncMock(
+ return_value=SentActivity(id="existing-msg-id", activity_params=MessageActivityInput(text="updated"))
)
+ app.api.conversations.activities = MagicMock(return_value=activities)
+ _wire_flat_activity_methods(app.api, activities)
+ app.api.clone = MagicMock(return_value=app.api)
with pytest.raises(ValueError, match="app not initialized"):
await app.send("conv-1", "hello")
@@ -868,6 +988,15 @@ async def test_initialize_enables_send(self):
result = await app.send("conv-1", "hello")
assert result.id == "msg-1"
+ activity = MessageActivityInput(text="updated")
+ activity.id = "existing-msg-id"
+ result = await app.send("conv-1", activity)
+ assert result.id == "existing-msg-id"
+ activities.update.assert_called_once_with(
+ "existing-msg-id",
+ activity,
+ )
+
@pytest.mark.asyncio
async def test_initialize_emits_error_on_plugin_failure(self):
"""If a plugin's on_init raises, the error event fires and the exception propagates."""
@@ -901,34 +1030,79 @@ def started_app(self):
options = AppOptions(client_id="test-client-id", client_secret="test-secret")
app = App(**options)
app._initialized = True
- app.activity_sender.send = AsyncMock(
+ create = AsyncMock(
return_value=SentActivity(id="sent-activity-id", activity_params=MessageActivityInput(text="sent"))
)
+ activities = MagicMock()
+ activities.create = create
+ activities.update = AsyncMock(
+ return_value=SentActivity(id="updated-activity-id", activity_params=MessageActivityInput(text="updated"))
+ )
+ app.api.conversations.activities = MagicMock(return_value=activities)
+ _wire_flat_activity_methods(app.api, activities)
+ app.api.clone = MagicMock(return_value=app.api)
return app
@pytest.mark.asyncio
async def test_reply_with_three_args_constructs_threaded_id(self, started_app):
await started_app.reply("19:abc@thread.skype", "1680000000000", "Hello thread")
- started_app.activity_sender.send.assert_called_once()
- _, ref = started_app.activity_sender.send.call_args[0]
- assert ref.conversation.id == "19:abc@thread.skype;messageid=1680000000000"
+ started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=1680000000000")
+
+ @pytest.mark.asyncio
+ async def test_reply_with_three_args_passes_agentic_identity_with_api_service_url(self, started_app):
+ agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+
+ await started_app.reply(
+ "19:abc@thread.skype",
+ "1680000000000",
+ "Hello thread",
+ agentic_identity=agentic_identity,
+ )
+
+ started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=1680000000000")
+ started_app.api.clone.assert_called_once_with(
+ service_url=started_app.api.service_url,
+ agentic_identity=agentic_identity,
+ )
+ create = started_app.api.conversations.activities.return_value.create
+ activity = create.call_args.args[0]
+ assert isinstance(activity, MessageActivityInput)
+ assert activity.text == "Hello thread"
+ assert create.call_args.kwargs == {}
@pytest.mark.asyncio
async def test_reply_with_two_args_passes_conversation_id_as_is(self, started_app):
await started_app.reply("19:abc@thread.skype", "Hello flat")
- started_app.activity_sender.send.assert_called_once()
- _, ref = started_app.activity_sender.send.call_args[0]
- assert ref.conversation.id == "19:abc@thread.skype"
+ started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype")
+
+ @pytest.mark.asyncio
+ async def test_reply_with_two_args_passes_agentic_identity_with_api_service_url(self, started_app):
+ agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+
+ await started_app.reply(
+ "19:abc@thread.skype",
+ "Hello flat",
+ agentic_identity=agentic_identity,
+ )
+
+ started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype")
+ started_app.api.clone.assert_called_once_with(
+ service_url=started_app.api.service_url,
+ agentic_identity=agentic_identity,
+ )
+ create = started_app.api.conversations.activities.return_value.create
+ activity = create.call_args.args[0]
+ assert isinstance(activity, MessageActivityInput)
+ assert activity.text == "Hello flat"
+ assert create.call_args.kwargs == {}
@pytest.mark.asyncio
async def test_reply_with_pre_constructed_threaded_id(self, started_app):
await started_app.reply("19:abc@thread.skype;messageid=123", "Hello")
- started_app.activity_sender.send.assert_called_once()
- _, ref = started_app.activity_sender.send.call_args[0]
- assert ref.conversation.id == "19:abc@thread.skype;messageid=123"
+ started_app.api.conversations.activities.assert_called_once_with("19:abc@thread.skype;messageid=123")
@pytest.mark.asyncio
async def test_reply_with_invalid_message_id_raises(self, started_app):
diff --git a/packages/apps/tests/test_app_oauth.py b/packages/apps/tests/test_app_oauth.py
index 9cc63ee08..13680e4f9 100644
--- a/packages/apps/tests/test_app_oauth.py
+++ b/packages/apps/tests/test_app_oauth.py
@@ -27,6 +27,7 @@
)
from microsoft_teams.apps.app_oauth import OauthHandlers
from microsoft_teams.apps.app_process import ActivityProcessor
+from microsoft_teams.apps.auth_provider import AppAuthProvider
from microsoft_teams.apps.events import ErrorEvent, SignInEvent
from microsoft_teams.apps.routing import ActivityContext
from microsoft_teams.apps.routing.activity_route_configs import ACTIVITY_ROUTES
@@ -445,8 +446,8 @@ def processor(self, router):
default_connection_name="graph",
http_client=MagicMock(),
token_manager=MagicMock(),
+ auth_provider=MagicMock(spec=AppAuthProvider),
api_client_settings=None,
- activity_sender=MagicMock(),
cloud=PUBLIC,
)
@@ -462,7 +463,6 @@ def _make_ctx(activity):
conversation_ref=MagicMock(),
is_signed_in=False,
connection_name="graph",
- activity_sender=MagicMock(),
app_token=MagicMock(),
cloud=PUBLIC,
)
diff --git a/packages/apps/tests/test_app_process.py b/packages/apps/tests/test_app_process.py
index 847a3c0b2..d2adb5aa8 100644
--- a/packages/apps/tests/test_app_process.py
+++ b/packages/apps/tests/test_app_process.py
@@ -5,7 +5,7 @@
# pyright: basic
from typing import Any
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from microsoft_teams.api import (
@@ -17,9 +17,9 @@
)
from microsoft_teams.api.auth.cloud_environment import PUBLIC
from microsoft_teams.apps import ActivityContext, ActivityEvent
-from microsoft_teams.apps.activity_sender import ActivitySender
from microsoft_teams.apps.app_events import EventManager
from microsoft_teams.apps.app_process import ActivityProcessor
+from microsoft_teams.apps.auth_provider import AppAuthProvider
from microsoft_teams.apps.events import CoreActivity
from microsoft_teams.apps.routing.router import ActivityHandler, ActivityRouter
from microsoft_teams.apps.token_manager import TokenManager
@@ -34,6 +34,7 @@ def mock_logger(self):
@pytest.fixture
def mock_http_client(self):
http_client = MagicMock(spec=Client)
+ http_client.token = None
http_client.clone.return_value = http_client
return http_client
@@ -43,11 +44,7 @@ def activity_processor(self, mock_http_client):
mock_storage = MagicMock(spec=LocalStorage)
mock_activity_router = MagicMock(spec=ActivityRouter)
mock_token_manager = MagicMock(spec=TokenManager)
- mock_activity_sender = MagicMock(spec=ActivitySender)
- # Mock the stream object with async close
- mock_stream = MagicMock()
- mock_stream.close = AsyncMock()
- mock_activity_sender.create_stream.return_value = mock_stream
+ mock_auth_provider = MagicMock(spec=AppAuthProvider)
return ActivityProcessor(
mock_activity_router,
"id",
@@ -55,8 +52,8 @@ def activity_processor(self, mock_http_client):
"default_connection",
mock_http_client,
mock_token_manager,
+ mock_auth_provider,
None,
- mock_activity_sender,
PUBLIC,
)
@@ -72,18 +69,16 @@ async def test_execute_middleware_chain_with_no_handlers(self, activity_processo
@pytest.mark.asyncio
async def test_execute_middleware_chain_with_two_handlers(self, activity_processor, mock_http_client):
"""Test the execute_middleware_chain method with two handlers."""
- mock_activity_sender = MagicMock(spec=ActivitySender)
- mock_activity_sender.create_stream.return_value = MagicMock()
+ api = MagicMock()
context = ActivityContext(
activity=MagicMock(spec=ActivityBase),
app_id="app_id",
storage=MagicMock(spec=LocalStorage),
- api=mock_http_client,
+ api=api,
user_token=None,
conversation_ref=MagicMock(spec=ConversationReference),
is_signed_in=True,
connection_name="default_connection",
- activity_sender=mock_activity_sender,
app_token=lambda: None,
cloud=PUBLIC,
)
@@ -209,22 +204,36 @@ async def test_updated_send_emits_activity_sent_event(self, activity_processor):
mock_token.service_url = "https://service.url"
mock_activity_event = ActivityEvent(body=core_activity, token=mock_token)
- # Activity sender returns a SentActivity from send()
+ # ApiClient returns a SentActivity from send()
sent = SentActivity(id="sent-1", activity_params=MessageActivityInput(text="hi"))
- activity_processor.activity_sender.send = AsyncMock(return_value=sent)
+ activities = MagicMock()
+ activities.create = AsyncMock(return_value=sent)
+ activity_processor.http_client.clone.return_value = activity_processor.http_client
+ with patch("microsoft_teams.apps.app_process.ApiClient") as mock_api_client:
+ mock_context_api = MagicMock()
+ mock_context_api.users.token.get = AsyncMock(side_effect=Exception("no token"))
+ mock_context_api.conversations.activities.return_value = activities
+
+ async def create_activity(conversation_id: str, activity: MessageActivityInput) -> SentActivity:
+ mock_context_api.conversations.activities(conversation_id)
+ return await activities.create(activity)
+
+ mock_context_api.conversations.create_activity = AsyncMock(side_effect=create_activity)
+ mock_context_api.clone.return_value = mock_context_api
+ mock_api_client.return_value = mock_context_api
+
+ # Handler that calls ctx.send to exercise the updated_send wrapper
+ async def calling_handler(ctx):
+ await ctx.send("hi")
+ return None
+
+ activity_processor.router.select_handlers = MagicMock(return_value=[calling_handler])
+ activity_processor.event_manager = MagicMock()
+ activity_processor.event_manager.on_activity_response = AsyncMock()
+ activity_processor.event_manager.on_activity_sent = AsyncMock()
+ activity_processor.event_manager.on_error = AsyncMock()
- # Handler that calls ctx.send to exercise the updated_send wrapper
- async def calling_handler(ctx):
- await ctx.send("hi")
- return None
-
- activity_processor.router.select_handlers = MagicMock(return_value=[calling_handler])
- activity_processor.event_manager = MagicMock()
- activity_processor.event_manager.on_activity_response = AsyncMock()
- activity_processor.event_manager.on_activity_sent = AsyncMock()
- activity_processor.event_manager.on_error = AsyncMock()
-
- await activity_processor.process_activity([], mock_activity_event)
+ await activity_processor.process_activity([], mock_activity_event)
activity_processor.event_manager.on_activity_sent.assert_called_once()
@@ -248,24 +257,26 @@ async def test_stream_handlers_emit_activity_sent_events(self, activity_processo
mock_token.service_url = "https://service.url"
mock_activity_event = ActivityEvent(body=core_activity, token=mock_token)
- mock_stream = activity_processor.activity_sender.create_stream.return_value
-
activity_processor.router.select_handlers = MagicMock(return_value=[])
activity_processor.event_manager = MagicMock()
activity_processor.event_manager.on_activity_response = AsyncMock()
activity_processor.event_manager.on_activity_sent = AsyncMock()
activity_processor.event_manager.on_error = AsyncMock()
- await activity_processor.process_activity([], mock_activity_event)
+ with patch("microsoft_teams.apps.routing.activity_context.HttpStream") as mock_stream_class:
+ mock_stream = mock_stream_class.return_value
+ mock_stream.close = AsyncMock()
- # Stream's on_chunk and on_close were registered with the inner handlers.
- # Invoke them to exercise their bodies.
- chunk_handler = mock_stream.on_chunk.call_args[0][0]
- close_handler = mock_stream.on_close.call_args[0][0]
+ await activity_processor.process_activity([], mock_activity_event)
- sent = SentActivity(id="chunk-1", activity_params=MessageActivityInput(text="chunk"))
- await chunk_handler(sent)
- await close_handler(sent)
+ # Stream's on_chunk and on_close were registered with the inner handlers.
+ # Invoke them to exercise their bodies.
+ chunk_handler = mock_stream.on_chunk.call_args[0][0]
+ close_handler = mock_stream.on_close.call_args[0][0]
+
+ sent = SentActivity(id="chunk-1", activity_params=MessageActivityInput(text="chunk"))
+ await chunk_handler(sent)
+ await close_handler(sent)
assert activity_processor.event_manager.on_activity_sent.call_count == 2
@@ -305,6 +316,46 @@ async def test_build_context_marks_signed_in_when_token_available(self, activity
mock_api_client.users.get_token.assert_called_once()
+ @pytest.mark.asyncio
+ async def test_build_context_scopes_api_to_inbound_agentic_identity(self, activity_processor):
+ """Inbound Agent ID activities scope ctx.api with the inbound agentic identity."""
+ core_activity = CoreActivity(
+ type="message",
+ id="activity-agentic",
+ service_url="https://service.url",
+ **{
+ "from": {"id": "user-1", "name": "Test User"},
+ "conversation": {"id": "conv-1"},
+ "recipient": {
+ "id": "bot-1",
+ "name": "Test Bot",
+ "agenticAppId": "agentic-app-id",
+ "agenticUserId": "agentic-user-id",
+ "tenantId": "tenant-id",
+ },
+ "channelId": "msteams",
+ },
+ )
+ mock_token = MagicMock(spec=TokenProtocol)
+ mock_token.service_url = "https://service.url"
+ mock_activity_event = ActivityEvent(body=core_activity, token=mock_token)
+ mock_api_client = MagicMock()
+ mock_api_client.users.token.get = AsyncMock(side_effect=Exception("no token"))
+
+ activity_processor.router.select_handlers = MagicMock(return_value=[])
+ activity_processor.event_manager = MagicMock()
+ activity_processor.event_manager.on_activity_response = AsyncMock()
+ activity_processor.event_manager.on_error = AsyncMock()
+
+ with patch("microsoft_teams.apps.app_process.ApiClient", return_value=mock_api_client) as mock_api_client_type:
+ await activity_processor.process_activity([], mock_activity_event)
+
+ assert mock_api_client_type.call_args.kwargs["auth_provider"] is activity_processor.auth_provider
+ agentic_identity = mock_api_client_type.call_args.kwargs["agentic_identity"]
+ assert agentic_identity.agentic_app_id == "agentic-app-id"
+ assert agentic_identity.agentic_user_id == "agentic-user-id"
+ assert agentic_identity.tenant_id == "tenant-id"
+
@pytest.mark.asyncio
async def test_process_activity_raises_when_event_manager_missing(self, activity_processor):
"""process_activity raises ValueError if event_manager was never initialized."""
diff --git a/packages/apps/tests/test_function_context.py b/packages/apps/tests/test_function_context.py
index cdd405a63..33c6d86fe 100644
--- a/packages/apps/tests/test_function_context.py
+++ b/packages/apps/tests/test_function_context.py
@@ -7,9 +7,8 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
-from microsoft_teams.api import ApiClient
+from microsoft_teams.api import ApiClient, SentActivity
from microsoft_teams.api.activities.message.message import MessageActivityInput
-from microsoft_teams.api.models.conversation.conversation_reference import ConversationReference
from microsoft_teams.apps import FunctionContext
from microsoft_teams.cards import AdaptiveCard
@@ -26,30 +25,41 @@ def mock_api(self):
mock_conversations = MagicMock()
mock_conversations.create = AsyncMock(return_value=MagicMock(id="new-conv"))
mock_conversations.get_member_by_id = AsyncMock(return_value=True)
+ mock_activities = MagicMock()
+ mock_activities.create = AsyncMock(
+ return_value=SentActivity(id="sent-activity", activity_params=MessageActivityInput(text="sent"))
+ )
+ mock_activities.update = AsyncMock(
+ return_value=SentActivity(id="updated-activity", activity_params=MessageActivityInput(text="updated"))
+ )
+ mock_conversations.activities.return_value = mock_activities
+
+ async def create_activity(conversation_id: str, activity: Any) -> SentActivity:
+ mock_conversations.activities(conversation_id)
+ return await mock_activities.create(activity)
+
+ async def update_activity(conversation_id: str, activity_id: str, activity: Any) -> SentActivity:
+ mock_conversations.activities(conversation_id)
+ return await mock_activities.update(activity_id, activity)
+
+ mock_conversations.create_activity = AsyncMock(side_effect=create_activity)
+ mock_conversations.update_activity = AsyncMock(side_effect=update_activity)
api.conversations = mock_conversations
+ api.clone.return_value = api
return api
- @pytest.fixture
- def mock_http(self):
- """Create a mock activity sender."""
- http = MagicMock()
- http.send = AsyncMock()
- http.send.return_value = "sent-activity"
- return http
-
@pytest.fixture
def mock_logger(self):
"""Create a mock Logger."""
return MagicMock()
@pytest.fixture
- def function_context(self, mock_api: ApiClient, mock_http: Any) -> FunctionContext[Any]:
+ def function_context(self, mock_api: ApiClient) -> FunctionContext[Any]:
ctx: FunctionContext[Any] = FunctionContext(
id="bot-123",
name="Test Bot",
api=mock_api,
- activity_sender=mock_http,
data={"some": "payload"},
app_session_id="dummy-session",
tenant_id="tenant-789",
@@ -64,51 +74,66 @@ def function_context(self, mock_api: ApiClient, mock_http: Any) -> FunctionConte
async def test_send_string_activity(
self,
function_context: FunctionContext[Any],
- mock_http: Any,
) -> None:
"""Test sending a string message."""
result = await function_context.send("Hello world")
- assert result == "sent-activity"
+ assert result is not None
+ assert result.id == "sent-activity"
- sent_activity, conversation_ref = mock_http.send.call_args[0]
+ sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0]
assert isinstance(sent_activity, MessageActivityInput)
assert sent_activity.text == "Hello world"
-
- assert isinstance(conversation_ref, ConversationReference)
- assert conversation_ref.conversation.id == "conv-123"
+ assert sent_activity.from_.id == "bot-123"
+ assert sent_activity.conversation.id == "conv-123"
+ function_context.api.conversations.activities.assert_called_once_with("conv-123")
async def test_send_adaptive_card(
self,
function_context: FunctionContext[Any],
- mock_http: Any,
) -> None:
"""Test sending an AdaptiveCard message."""
card = AdaptiveCard(schema="1.0")
result = await function_context.send(card)
- assert result == "sent-activity"
+ assert result is not None
+ assert result.id == "sent-activity"
- sent_activity, conversation_ref = mock_http.send.call_args[0]
+ sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.attachments[0].content == card
- assert conversation_ref.conversation.id == "conv-123"
+ function_context.api.conversations.activities.assert_called_once_with("conv-123")
- async def test_send_creates_conversation_if_none(
- self, function_context: FunctionContext[Any], mock_http: Any
- ) -> None:
+ async def test_send_creates_conversation_if_none(self, function_context: FunctionContext[Any]) -> None:
"""Test send() creates a conversation when _resolved_conversation_id is None."""
function_context.chat_id = None
- mock_http.send.return_value = "sent-new-conv"
+ function_context.api.conversations.activities.return_value.create.return_value = SentActivity(
+ id="sent-new-conv", activity_params=MessageActivityInput(text="sent")
+ )
result = await function_context.send("Hello new conversation")
- assert result == "sent-new-conv"
+ assert result is not None
+ assert result.id == "sent-new-conv"
# Ensure conversation was created
assert function_context.api.conversations.create.call_count == 1 # type: ignore
- sent_activity, conversation_ref = mock_http.send.call_args[0]
+ sent_activity = function_context.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == "Hello new conversation"
- assert conversation_ref.conversation.id == "new-conv"
+ function_context.api.conversations.activities.assert_called_with("new-conv")
+
+ async def test_send_existing_activity_updates(self, function_context: FunctionContext[Any]) -> None:
+ activity = MessageActivityInput(text="Updated message")
+ activity.id = "existing-msg-id"
+
+ result = await function_context.send(activity)
+
+ assert result is not None
+ assert result.id == "updated-activity"
+ function_context.api.conversations.activities.return_value.update.assert_called_once_with(
+ "existing-msg-id",
+ activity,
+ )
+ function_context.api.conversations.activities.return_value.create.assert_not_called()
diff --git a/packages/apps/tests/test_http_stream.py b/packages/apps/tests/test_http_stream.py
index 827f20526..3e2b3752d 100644
--- a/packages/apps/tests/test_http_stream.py
+++ b/packages/apps/tests/test_http_stream.py
@@ -41,6 +41,7 @@ def mock_api_client(self):
mock_conversations = MagicMock()
client.conversations = mock_conversations
+ client.from_service_url.return_value = client
client.send_call_count = 0
client.sent_activities = []
diff --git a/packages/apps/tests/test_optional_graph_dependencies.py b/packages/apps/tests/test_optional_graph_dependencies.py
index 2045e88dd..b01c56907 100644
--- a/packages/apps/tests/test_optional_graph_dependencies.py
+++ b/packages/apps/tests/test_optional_graph_dependencies.py
@@ -65,8 +65,6 @@ def _create_activity_context(self) -> ActivityContext[Any]:
mock_storage = MagicMock()
mock_api = MagicMock()
mock_conversation_ref = MagicMock()
- mock_activity_sender = MagicMock()
- mock_activity_sender.create_stream.return_value = MagicMock()
mock_app_token = MagicMock() # Provide an app token for graph access
return ActivityContext(
@@ -78,7 +76,6 @@ def _create_activity_context(self) -> ActivityContext[Any]:
conversation_ref=mock_conversation_ref,
is_signed_in=False,
connection_name="test-connection",
- activity_sender=mock_activity_sender,
app_token=mock_app_token, # This is needed for app_graph to work
cloud=PUBLIC,
)
@@ -126,7 +123,6 @@ def test_user_graph_property_not_signed_in(self) -> None:
conversation_ref=MagicMock(),
is_signed_in=False, # Not signed in
connection_name="test-connection",
- activity_sender=MagicMock(),
app_token=None,
cloud=PUBLIC,
)
@@ -146,7 +142,6 @@ def test_user_graph_property_no_token(self) -> None:
conversation_ref=MagicMock(),
is_signed_in=True, # Signed in but no token
connection_name="test-connection",
- activity_sender=MagicMock(),
app_token=None,
cloud=PUBLIC,
)
@@ -166,7 +161,6 @@ def test_app_graph_property_no_token(self) -> None:
conversation_ref=MagicMock(),
is_signed_in=False,
connection_name="test-connection",
- activity_sender=MagicMock(),
app_token=None, # No app token
cloud=PUBLIC,
)
diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py
index 712888c1d..8e5feb79f 100644
--- a/packages/apps/tests/test_quoted_reply.py
+++ b/packages/apps/tests/test_quoted_reply.py
@@ -7,7 +7,7 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
-from microsoft_teams.api import Account, MessageActivityInput, SentActivity
+from microsoft_teams.api import Account, ConversationAccount, ConversationReference, MessageActivityInput, SentActivity
from microsoft_teams.api.models.entity import QuotedReplyEntity
from microsoft_teams.apps.routing.activity_context import ActivityContext
@@ -36,19 +36,34 @@ def _create_activity_context(
)
)
mock_activity_sender.create_stream = MagicMock(return_value=MagicMock())
-
- mock_conversation_ref = MagicMock()
+ activities = MagicMock()
+ activities.create = mock_activity_sender.send
+ api = MagicMock()
+ api.conversations.activities.return_value = activities
+ api.clone.return_value = api
+
+ async def create_activity(conversation_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.create(activity)
+
+ api.conversations.create_activity = AsyncMock(side_effect=create_activity)
+
+ conversation_ref = ConversationReference(
+ bot=Account(id="bot-id", name="Test Bot"),
+ conversation=ConversationAccount(id="test-conversation"),
+ channel_id="msteams",
+ service_url="https://service.example",
+ )
return ActivityContext(
activity=mock_activity,
app_id="test-app-id",
storage=MagicMock(),
- api=MagicMock(),
+ api=api,
user_token=None,
- conversation_ref=mock_conversation_ref,
+ conversation_ref=conversation_ref,
is_signed_in=False,
connection_name="test-connection",
- activity_sender=mock_activity_sender,
app_token=MagicMock(),
)
@@ -58,7 +73,7 @@ async def test_reply_stamps_quoted_reply_entity(self) -> None:
ctx = self._create_activity_context(activity_id="msg-abc")
await ctx.reply("Thanks!")
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.entities is not None
assert len(sent_activity.entities) == 1
entity = sent_activity.entities[0]
@@ -71,7 +86,7 @@ async def test_reply_prepends_placeholder(self) -> None:
ctx = self._create_activity_context(activity_id="msg-abc")
await ctx.reply("Thanks!")
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == ' Thanks!'
@pytest.mark.asyncio
@@ -81,7 +96,7 @@ async def test_reply_with_empty_text(self) -> None:
activity = MessageActivityInput(text="")
await ctx.reply(activity)
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == ''
@pytest.mark.asyncio
@@ -91,7 +106,7 @@ async def test_reply_with_none_text(self) -> None:
activity = MessageActivityInput()
await ctx.reply(activity)
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == ''
@pytest.mark.asyncio
@@ -100,7 +115,7 @@ async def test_reply_with_no_activity_id(self) -> None:
ctx = self._create_activity_context(activity_id=None)
await ctx.reply("Thanks!")
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == "Thanks!"
assert sent_activity.entities is None
@@ -114,7 +129,7 @@ async def test_reply_preserves_existing_entities(self) -> None:
await ctx.reply(activity)
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.entities is not None
assert len(sent_activity.entities) == 2
assert sent_activity.entities[0] is existing_entity
@@ -127,7 +142,7 @@ async def test_reply_with_activity_params(self) -> None:
activity = MessageActivityInput(text="Hello world")
await ctx.reply(activity)
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == ' Hello world'
@@ -148,19 +163,34 @@ def _create_activity_context(self) -> ActivityContext[Any]:
)
)
mock_activity_sender.create_stream = MagicMock(return_value=MagicMock())
-
- mock_conversation_ref = MagicMock()
+ activities = MagicMock()
+ activities.create = mock_activity_sender.send
+ api = MagicMock()
+ api.conversations.activities.return_value = activities
+ api.clone.return_value = api
+
+ async def create_activity(conversation_id: str, activity: Any) -> SentActivity:
+ api.conversations.activities(conversation_id)
+ return await activities.create(activity)
+
+ api.conversations.create_activity = AsyncMock(side_effect=create_activity)
+
+ conversation_ref = ConversationReference(
+ bot=Account(id="bot-id", name="Test Bot"),
+ conversation=ConversationAccount(id="test-conversation"),
+ channel_id="msteams",
+ service_url="https://service.example",
+ )
return ActivityContext(
activity=mock_activity,
app_id="test-app-id",
storage=MagicMock(),
- api=MagicMock(),
+ api=api,
user_token=None,
- conversation_ref=mock_conversation_ref,
+ conversation_ref=conversation_ref,
is_signed_in=False,
connection_name="test-connection",
- activity_sender=mock_activity_sender,
app_token=MagicMock(),
)
@@ -170,7 +200,7 @@ async def test_quote_stamps_entity_with_message_id(self) -> None:
ctx = self._create_activity_context()
await ctx.quote("arbitrary-msg-id", "Quoting this!")
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.entities is not None
assert len(sent_activity.entities) == 1
entity = sent_activity.entities[0]
@@ -183,7 +213,7 @@ async def test_quote_prepends_placeholder(self) -> None:
ctx = self._create_activity_context()
await ctx.quote("msg-xyz", "My reply text")
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == ' My reply text'
@pytest.mark.asyncio
@@ -193,7 +223,7 @@ async def test_quote_with_empty_text(self) -> None:
activity = MessageActivityInput(text="")
await ctx.quote("msg-xyz", activity)
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == ''
@pytest.mark.asyncio
@@ -203,7 +233,7 @@ async def test_quote_with_activity_params(self) -> None:
activity = MessageActivityInput(text="Hello world")
await ctx.quote("msg-xyz", activity)
- sent_activity = ctx._activity_sender.send.call_args[0][0]
+ sent_activity = ctx.api.conversations.activities.return_value.create.call_args[0][0]
assert sent_activity.text == ' Hello world'
assert sent_activity.entities is not None
assert len(sent_activity.entities) == 1
diff --git a/packages/apps/tests/test_token_manager.py b/packages/apps/tests/test_token_manager.py
index dd75d3601..ac24f7034 100644
--- a/packages/apps/tests/test_token_manager.py
+++ b/packages/apps/tests/test_token_manager.py
@@ -6,17 +6,20 @@
# pyright: basic
from typing import Literal, cast
-from unittest.mock import MagicMock, create_autospec, patch
+from unittest.mock import AsyncMock, MagicMock, create_autospec, patch
import pytest
from microsoft_teams.api import (
+ AgenticIdentity,
ClientCredentials,
FederatedIdentityCredentials,
JsonWebToken,
ManagedIdentityCredentials,
)
+from microsoft_teams.api.auth.cloud_environment import PUBLIC
from microsoft_teams.api.auth.credentials import TokenCredentials
-from microsoft_teams.apps.token_manager import TokenManager
+from microsoft_teams.apps.auth_provider import AppAuthProvider
+from microsoft_teams.apps.token_manager import AGENT_BOT_API_SCOPE, TOKEN_EXCHANGE_SCOPE, TokenManager
from msal import ManagedIdentityClient # pyright: ignore[reportMissingTypeStubs]
# Valid JWT-like token for testing (format: header.payload.signature)
@@ -30,6 +33,266 @@
class TestTokenManager:
"""Test TokenManager functionality."""
+ @pytest.mark.asyncio
+ async def test_get_agentic_token_uses_agent_identity_flow(self):
+ mock_credentials = ClientCredentials(
+ client_id="blueprint-client-id",
+ client_secret="blueprint-client-secret",
+ tenant_id="tenant-id",
+ )
+
+ blueprint_app = MagicMock()
+ blueprint_app.acquire_token_for_client.return_value = {"access_token": "t1-token"}
+
+ agent_app = MagicMock()
+ agent_app.acquire_token_for_client.side_effect = lambda _scopes: (
+ mock_confidential_app.call_args_list[1].kwargs["client_credential"]["client_assertion"]({}),
+ {"access_token": "t2-token"},
+ )[1]
+ agent_app.acquire_token_by_user_federated_identity_credential.return_value = {"access_token": VALID_TEST_TOKEN}
+
+ with patch("microsoft_teams.apps.token_manager.ConfidentialClientApplication") as mock_confidential_app:
+ mock_confidential_app.side_effect = [blueprint_app, agent_app]
+
+ manager = TokenManager(credentials=mock_credentials)
+ token = await manager.get_agentic_token(
+ AGENT_BOT_API_SCOPE,
+ AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id"),
+ )
+
+ assert token is not None
+ assert str(token) == VALID_TEST_TOKEN
+
+ blueprint_app.acquire_token_for_client.assert_called_once_with(
+ [TOKEN_EXCHANGE_SCOPE], fmi_path="agentic-app-id"
+ )
+ agent_app.acquire_token_for_client.assert_called_once_with([TOKEN_EXCHANGE_SCOPE])
+ agent_app.acquire_token_by_user_federated_identity_credential.assert_called_once_with(
+ [AGENT_BOT_API_SCOPE],
+ assertion="t2-token",
+ user_object_id="agentic-user-id",
+ username=None,
+ data={"requested_token_use": "on_behalf_of"},
+ )
+
+ first_call, second_call = mock_confidential_app.call_args_list
+ assert first_call.args == ("blueprint-client-id",)
+ assert first_call.kwargs == {
+ "client_credential": "blueprint-client-secret",
+ "authority": "https://login.microsoftonline.com/tenant-id",
+ }
+ assert second_call.args == ("agentic-app-id",)
+ assert second_call.kwargs["authority"] == "https://login.microsoftonline.com/tenant-id"
+ assert callable(second_call.kwargs["client_credential"]["client_assertion"])
+
+ @pytest.mark.asyncio
+ async def test_get_agentic_token_caches_agent_identity_client(self):
+ mock_credentials = ClientCredentials(
+ client_id="blueprint-client-id",
+ client_secret="blueprint-client-secret",
+ tenant_id="tenant-id",
+ )
+
+ blueprint_app = MagicMock()
+ blueprint_app.acquire_token_for_client.return_value = {"access_token": "t1-token"}
+
+ agent_app = MagicMock()
+ agent_app.acquire_token_for_client.return_value = {"access_token": "t2-token"}
+ agent_app.acquire_token_by_user_federated_identity_credential.return_value = {"access_token": VALID_TEST_TOKEN}
+
+ with patch("microsoft_teams.apps.token_manager.ConfidentialClientApplication") as mock_confidential_app:
+ mock_confidential_app.side_effect = [blueprint_app, agent_app]
+
+ manager = TokenManager(credentials=mock_credentials)
+ await manager.get_agentic_token(
+ AGENT_BOT_API_SCOPE, AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ )
+ await manager.get_agentic_token(
+ AGENT_BOT_API_SCOPE, AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ )
+
+ assert mock_confidential_app.call_count == 2
+
+ @pytest.mark.asyncio
+ async def test_get_agentic_token_with_token_credentials_passes_agentic_identity(self):
+ calls = []
+
+ async def token_provider(scope: str, tenant_id: str | None, *, agentic_identity: AgenticIdentity | None):
+ calls.append((scope, tenant_id, agentic_identity))
+ return VALID_TEST_TOKEN
+
+ credentials = TokenCredentials(client_id="blueprint-client-id", token=token_provider, tenant_id="tenant-id")
+ manager = TokenManager(credentials=credentials)
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity)
+
+ assert token is not None
+ assert str(token) == VALID_TEST_TOKEN
+ assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", identity)]
+
+ @pytest.mark.asyncio
+ async def test_get_agentic_token_with_token_credentials_accepts_positional_identity(self):
+ calls = []
+
+ def token_provider(scope: str, tenant_id: str | None, identity: AgenticIdentity | None):
+ calls.append((scope, tenant_id, identity))
+ return VALID_TEST_TOKEN
+
+ credentials = TokenCredentials(client_id="blueprint-client-id", token=token_provider, tenant_id="tenant-id")
+ manager = TokenManager(credentials=credentials)
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+ token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity)
+
+ assert token is not None
+ assert str(token) == VALID_TEST_TOKEN
+ assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", identity)]
+
+ @pytest.mark.asyncio
+ async def test_get_token_with_required_third_argument_passes_none_for_non_agentic_token(self):
+ calls = []
+
+ def token_provider(scope: str, tenant_id: str | None, identity: AgenticIdentity | None):
+ calls.append((scope, tenant_id, identity))
+ return VALID_TEST_TOKEN
+
+ credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id")
+ manager = TokenManager(credentials=credentials)
+
+ token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id")
+
+ assert str(token) == VALID_TEST_TOKEN
+ assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", None)]
+
+ @pytest.mark.asyncio
+ async def test_get_token_with_optional_third_argument_uses_default_for_non_agentic_token(self):
+ calls = []
+
+ def token_provider(scope: str, tenant_id: str | None, timeout: int = 30):
+ calls.append((scope, tenant_id, timeout))
+ return VALID_TEST_TOKEN
+
+ credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id")
+ manager = TokenManager(credentials=credentials)
+
+ token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id")
+
+ assert str(token) == VALID_TEST_TOKEN
+ assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id", 30)]
+
+ @pytest.mark.asyncio
+ async def test_token_provider_uninspectable_signature_uses_legacy_args_without_agentic_identity(self):
+ calls = []
+
+ def token_provider(scope: str, tenant_id: str | None):
+ calls.append((scope, tenant_id))
+ return VALID_TEST_TOKEN
+
+ credentials = TokenCredentials(client_id="test-client-id", token=token_provider, tenant_id="tenant-id")
+ manager = TokenManager(credentials=credentials)
+
+ with patch("microsoft_teams.apps.token_manager.signature", side_effect=ValueError("no signature")):
+ token = await manager._get_token_with_token_provider(credentials, AGENT_BOT_API_SCOPE, "tenant-id")
+
+ assert str(token) == VALID_TEST_TOKEN
+ assert calls == [(AGENT_BOT_API_SCOPE, "tenant-id")]
+
+ @pytest.mark.asyncio
+ async def test_token_provider_uninspectable_signature_rejects_agentic_identity(self):
+ credentials = TokenCredentials(
+ client_id="test-client-id",
+ token=lambda _scope, _tenant_id: VALID_TEST_TOKEN,
+ tenant_id="tenant-id",
+ )
+ manager = TokenManager(credentials=credentials)
+ agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+
+ with patch("microsoft_teams.apps.token_manager.signature", side_effect=ValueError("no signature")):
+ with pytest.raises(ValueError, match="Token provider must accept agentic_identity"):
+ await manager._get_token_with_token_provider(
+ credentials, AGENT_BOT_API_SCOPE, "tenant-id", agentic_identity
+ )
+
+ @pytest.mark.asyncio
+ async def test_app_auth_provider_uses_app_token_without_agentic_identity(self):
+ token_manager = MagicMock(spec=TokenManager)
+ token_manager.get_app_token = AsyncMock(return_value="app-token")
+ auth_provider = AppAuthProvider(token_manager, PUBLIC)
+
+ token = await auth_provider.token()
+
+ assert token == "app-token"
+ token_manager.get_app_token.assert_awaited_once_with(PUBLIC.bot_scope, caller_name="token")
+ token_manager.get_agentic_token.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_app_auth_provider_uses_agentic_token_with_agentic_identity(self):
+ token_manager = MagicMock(spec=TokenManager)
+ token_manager.get_agentic_token = AsyncMock(return_value="agentic-token")
+ auth_provider = AppAuthProvider(token_manager, PUBLIC)
+ agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id", tenant_id="tenant-id")
+
+ token = await auth_provider.token(agentic_identity=agentic_identity)
+
+ assert token == "agentic-token"
+ token_manager.get_agentic_token.assert_awaited_once_with(
+ AGENT_BOT_API_SCOPE,
+ agentic_identity,
+ caller_name="token",
+ )
+ token_manager.get_app_token.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_app_auth_provider_passes_missing_agentic_tenant_to_token_manager(self):
+ token_manager = MagicMock(spec=TokenManager)
+ token_manager.get_agentic_token = AsyncMock(return_value="agentic-token")
+ auth_provider = AppAuthProvider(token_manager, PUBLIC)
+ agentic_identity = AgenticIdentity("agentic-app-id", "agentic-user-id")
+
+ token = await auth_provider.token(agentic_identity=agentic_identity)
+
+ assert token == "agentic-token"
+ token_manager.get_agentic_token.assert_awaited_once_with(
+ AGENT_BOT_API_SCOPE,
+ agentic_identity,
+ caller_name="token",
+ )
+ token_manager.get_app_token.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_get_agentic_token_uses_credentials_tenant_when_missing(self):
+ calls = []
+
+ async def token_provider(scope: str, tenant_id: str | None, *, agentic_identity: AgenticIdentity | None):
+ calls.append((scope, tenant_id, agentic_identity))
+ return VALID_TEST_TOKEN
+
+ credentials = TokenCredentials(
+ client_id="blueprint-client-id", token=token_provider, tenant_id="credential-tenant-id"
+ )
+ manager = TokenManager(credentials=credentials)
+
+ identity = AgenticIdentity("agentic-app-id", "agentic-user-id")
+ token = await manager.get_agentic_token(AGENT_BOT_API_SCOPE, identity)
+
+ assert token is not None
+ assert calls == [(AGENT_BOT_API_SCOPE, "credential-tenant-id", identity)]
+
+ @pytest.mark.asyncio
+ async def test_get_agentic_token_requires_tenant_when_missing_from_request_and_credentials(self):
+ credentials = TokenCredentials(
+ client_id="blueprint-client-id",
+ token=lambda _scope, _tenant_id: VALID_TEST_TOKEN,
+ )
+ manager = TokenManager(credentials=credentials)
+
+ with pytest.raises(ValueError, match="tenant_id is required to get an agentic token"):
+ await manager.get_agentic_token(
+ AGENT_BOT_API_SCOPE,
+ AgenticIdentity("agentic-app-id", "agentic-user-id"),
+ )
+
@pytest.mark.asyncio
async def test_get_bot_token_success(self):
"""Test successful bot token retrieval using MSAL."""
diff --git a/packages/apps/tests/test_token_validator.py b/packages/apps/tests/test_token_validator.py
index eaa1bab60..f949be367 100644
--- a/packages/apps/tests/test_token_validator.py
+++ b/packages/apps/tests/test_token_validator.py
@@ -3,11 +3,12 @@
Licensed under the MIT License.
"""
-from unittest.mock import MagicMock, patch
+from unittest.mock import AsyncMock, MagicMock, patch
import jwt
import pytest
-from microsoft_teams.apps.auth.token_validator import TokenValidator
+from microsoft_teams.apps.auth import token_validator as token_validator_module
+from microsoft_teams.apps.auth.token_validator import InboundActivityTokenValidator, TokenValidator
# pyright: basic
@@ -407,3 +408,94 @@ async def test_validate_entra_token_v1_sts_issuer(self, mock_jwks_client):
result = await validator.validate_token("v1.entra.token")
assert result["iss"] == "https://sts.windows.net/test-tenant-id/"
assert result["ver"] == "1.0"
+
+
+class TestInboundActivityTokenValidator:
+ @pytest.mark.asyncio
+ async def test_validate_token_uses_service_validator_for_bot_framework_tokens(self):
+ validator = InboundActivityTokenValidator("test-app-id")
+ validator._service_validator.validate_token = AsyncMock(return_value={"iss": "https://api.botframework.com"})
+
+ with patch("jwt.decode", return_value={"iss": "https://api.botframework.com"}) as decode:
+ result = await validator.validate_token("bot-token", "https://service.example")
+
+ assert result == {"iss": "https://api.botframework.com"}
+ decode.assert_called_once_with("bot-token", algorithms=["RS256"], options={"verify_signature": False})
+ validator._service_validator.validate_token.assert_called_once_with("bot-token", "https://service.example")
+
+ @pytest.mark.asyncio
+ async def test_validate_token_uses_entra_validator_for_v2_issuer(self):
+ validator = InboundActivityTokenValidator("test-app-id")
+ validator._service_validator.validate_token = AsyncMock()
+ entra_validator = MagicMock()
+ entra_validator.validate_token = AsyncMock(return_value={"tid": "tenant-id"})
+
+ with patch.object(validator, "_get_entra_validator", return_value=entra_validator) as get_validator:
+ with patch(
+ "jwt.decode",
+ return_value={"iss": "https://login.microsoftonline.com/tenant-id/v2.0", "tid": "tenant-id"},
+ ):
+ result = await validator.validate_token("entra-token", "https://service.example")
+
+ assert result == {"tid": "tenant-id"}
+ get_validator.assert_called_once_with("tenant-id")
+ entra_validator.validate_token.assert_called_once_with("entra-token")
+ validator._service_validator.validate_token.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_validate_token_uses_entra_validator_for_v1_sts_issuer(self):
+ validator = InboundActivityTokenValidator("test-app-id")
+ entra_validator = MagicMock()
+ entra_validator.validate_token = AsyncMock(return_value={"tid": "tenant-id"})
+
+ with patch.object(validator, "_get_entra_validator", return_value=entra_validator) as get_validator:
+ with patch("jwt.decode", return_value={"iss": "https://sts.windows.net/tenant-id/", "tid": "tenant-id"}):
+ result = await validator.validate_token("entra-v1-token")
+
+ assert result == {"tid": "tenant-id"}
+ get_validator.assert_called_once_with("tenant-id")
+ entra_validator.validate_token.assert_called_once_with("entra-v1-token")
+
+ @pytest.mark.asyncio
+ async def test_validate_token_rejects_entra_token_without_tid(self):
+ validator = InboundActivityTokenValidator("test-app-id")
+
+ with patch("jwt.decode", return_value={"iss": "https://login.microsoftonline.com/tenant-id/v2.0"}):
+ with pytest.raises(jwt.InvalidTokenError, match="missing tid"):
+ await validator.validate_token("entra-token")
+
+ @pytest.mark.asyncio
+ async def test_validate_token_rejects_empty_token_before_routing_decode(self):
+ validator = InboundActivityTokenValidator("test-app-id")
+
+ with patch("jwt.decode") as decode:
+ with pytest.raises(jwt.InvalidTokenError, match="No token provided"):
+ await validator.validate_token("")
+
+ decode.assert_not_called()
+
+ def test_get_entra_validator_caches_by_tenant(self):
+ validator = InboundActivityTokenValidator("test-app-id")
+
+ with patch("microsoft_teams.apps.auth.token_validator.TokenValidator.for_entra") as for_entra:
+ for_entra.return_value = MagicMock()
+
+ first = validator._get_entra_validator("tenant-id")
+ second = validator._get_entra_validator("tenant-id")
+
+ assert first is second
+ for_entra.assert_called_once_with("test-app-id", "tenant-id", cloud=validator._cloud)
+
+ def test_get_entra_validator_cache_is_bounded(self):
+ validator = InboundActivityTokenValidator("test-app-id")
+
+ with patch("microsoft_teams.apps.auth.token_validator.TokenValidator.for_entra") as for_entra:
+ for_entra.side_effect = lambda _app_id, tenant_id, **_kwargs: MagicMock(name=tenant_id)
+
+ for index in range(token_validator_module._MAX_ENTRA_VALIDATOR_CACHE_SIZE + 1):
+ validator._get_entra_validator(f"tenant-{index}")
+
+ assert len(validator._entra_validators_by_tenant) == token_validator_module._MAX_ENTRA_VALIDATOR_CACHE_SIZE
+ assert "tenant-0" not in validator._entra_validators_by_tenant
+ last_tenant_id = f"tenant-{token_validator_module._MAX_ENTRA_VALIDATOR_CACHE_SIZE}"
+ assert last_tenant_id in validator._entra_validators_by_tenant
diff --git a/packages/common/src/microsoft_teams/common/http/client.py b/packages/common/src/microsoft_teams/common/http/client.py
index 5c9305130..757e6594f 100644
--- a/packages/common/src/microsoft_teams/common/http/client.py
+++ b/packages/common/src/microsoft_teams/common/http/client.py
@@ -6,7 +6,7 @@
import inspect
import json
import logging
-from dataclasses import dataclass, field
+from dataclasses import dataclass, field, replace
from typing import Any, Awaitable, Callable, Dict, List, Optional
import httpx
@@ -89,7 +89,7 @@ class ClientOptions:
headers: Dict[str, str] = field(default_factory=dict[str, str])
timeout: Optional[float] = None
token: Optional[Token] = None
- interceptors: Optional[List[Interceptor]] = field(default_factory=list[Interceptor])
+ interceptors: Optional[List[Interceptor]] = None
class Client:
@@ -100,7 +100,7 @@ class Client:
options: ClientOptions dataclass with configuration for the client.
"""
- def __init__(self, options: Optional[ClientOptions] = None):
+ def __init__(self, options: Optional[ClientOptions] = None, *, _http: Optional[httpx.AsyncClient] = None):
"""
Initialize the HTTP Client.
@@ -118,13 +118,29 @@ def __init__(self, options: Optional[ClientOptions] = None):
# Maintain interceptors as a separate instance attribute (do not mutate options)
self._interceptors = list(options.interceptors or [])
- self.http = httpx.AsyncClient(
+ self.http = _http or httpx.AsyncClient(
base_url=httpx.URL(options.base_url) if options.base_url else "",
headers=options.headers,
timeout=options.timeout,
)
self._update_event_hooks()
+ @property
+ def interceptors(self) -> tuple[Interceptor, ...]:
+ """Get the registered interceptors."""
+ return tuple(self._interceptors)
+
+ @property
+ def token(self) -> Optional[Token]:
+ """Get the default authorization token."""
+ return self._token
+
+ @token.setter
+ def token(self, value: Optional[Token]) -> None:
+ """Set the default authorization token."""
+ self._token = value
+ self._options = replace(self._options, token=value)
+
async def _prepare_headers(self, headers: Optional[Dict[str, str]], token: Optional[Token]) -> Dict[str, str]:
"""
Merge default and per-request headers, resolve token, and inject Authorization header if needed.
@@ -137,6 +153,8 @@ async def _prepare_headers(self, headers: Optional[Dict[str, str]], token: Optio
Final headers dict for the request.
"""
req_headers = {**self._options.headers, **(headers or {})}
+ if headers and any(key.lower() == "authorization" for key in headers):
+ return req_headers
resolved_token = await self._resolve_token(token)
if resolved_token:
req_headers["Authorization"] = f"Bearer {resolved_token}"
@@ -438,7 +456,7 @@ async def wrapper(response: Response) -> None:
event_hooks_dict.setdefault("response", []).append(_make_response_wrapper(hook))
self.http.event_hooks = event_hooks_dict
- def clone(self, overrides: Optional[ClientOptions] = None) -> "Client":
+ def clone(self, overrides: Optional[ClientOptions] = None, *, share_http: bool = False) -> "Client":
"""
Create a new Client instance with merged configuration.
@@ -453,9 +471,9 @@ def clone(self, overrides: Optional[ClientOptions] = None) -> "Client":
base_url=overrides.base_url if overrides.base_url is not None else self._options.base_url,
headers=_merge_headers(self._options.headers, overrides.headers or {}),
timeout=overrides.timeout if overrides.timeout is not None else self._options.timeout,
- token=overrides.token if overrides.token is not None else self._options.token,
+ token=overrides.token if overrides.token is not None else self._token,
interceptors=list(overrides.interceptors)
if overrides.interceptors is not None
else list(self._interceptors),
)
- return Client(merged_options)
+ return Client(merged_options, _http=self.http if share_http else None)
diff --git a/packages/common/tests/test_client.py b/packages/common/tests/test_client.py
index 529f38bd8..7a33415c7 100644
--- a/packages/common/tests/test_client.py
+++ b/packages/common/tests/test_client.py
@@ -119,6 +119,54 @@ async def test_clone_merges_options_and_interceptors(mock_transport):
assert interceptor2.request_called
+def test_interceptors_returns_read_only_copy():
+ interceptor1 = DummyAsyncInterceptor()
+ client = Client(ClientOptions(interceptors=[interceptor1]))
+
+ assert client.interceptors == (interceptor1,)
+
+
+def test_clone_copies_interceptor_list_independently():
+ interceptor1 = DummyAsyncInterceptor()
+ client = Client(ClientOptions(interceptors=[interceptor1]))
+
+ clone = client.clone()
+ interceptor2 = DummyAsyncInterceptor()
+ clone.use_interceptor(interceptor2)
+
+ assert client.interceptors == (interceptor1,)
+ assert clone.interceptors == (interceptor1, interceptor2)
+ assert client.interceptors is not clone.interceptors
+
+
+def test_clone_reuses_underlying_http_client_when_requested():
+ client = Client(ClientOptions(base_url="https://example.com"))
+
+ clone = client.clone(share_http=True)
+
+ assert clone is not client
+ assert clone.http is client.http
+
+
+def test_clone_uses_current_token_value():
+ client = Client(ClientOptions(token="original-token"))
+ client.token = None
+
+ clone = client.clone()
+
+ assert clone.token is None
+
+
+def test_clone_can_clear_interceptors_with_empty_override():
+ interceptor1 = DummyAsyncInterceptor()
+ client = Client(ClientOptions(interceptors=[interceptor1]))
+
+ clone = client.clone(ClientOptions(interceptors=[]))
+
+ assert client.interceptors == (interceptor1,)
+ assert clone.interceptors == ()
+
+
@pytest.mark.parametrize(
"token,expected",
[
@@ -171,6 +219,17 @@ async def test_async_none_token(mock_transport):
assert "authorization" not in data["headers"]
+@pytest.mark.asyncio
+async def test_explicit_authorization_header_wins_over_default_token(mock_transport):
+ client = Client(ClientOptions(base_url="https://example.com", token="default-token"))
+ client.http._transport = mock_transport
+
+ resp = await client.get("/token-test", headers={"Authorization": "******"})
+ data = resp.json()
+
+ assert data["headers"]["authorization"] == "******"
+
+
# Test token factory that raises an exception
def failing_token_factory() -> str:
raise ValueError("Token factory failed")
diff --git a/stubs/msal/__init__.pyi b/stubs/msal/__init__.pyi
index 0ec244167..5e5272ced 100644
--- a/stubs/msal/__init__.pyi
+++ b/stubs/msal/__init__.pyi
@@ -1,8 +1,8 @@
"""Type stubs for msal"""
-from typing import Any, Callable, TypeAlias
+from typing import Any, Callable, Optional, TypeAlias
-ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str]] | None
+ClientCredential: TypeAlias = str | dict[str, str | Callable[[], str] | Callable[[dict[str, Any]], str]] | None
class ConfidentialClientApplication:
"""MSAL Confidential Client Application"""
@@ -16,7 +16,22 @@ class ConfidentialClientApplication:
**kwargs: Any,
) -> None: ...
def acquire_token_for_client(
- self, scopes: list[str] | str, claims_challenge: str | None = None, **kwargs: Any
+ self,
+ scopes: list[str] | str,
+ claims_challenge: str | None = None,
+ *,
+ fmi_path: str | None = None,
+ **kwargs: Any,
+ ) -> dict[str, Any]: ...
+ def acquire_token_by_user_federated_identity_credential(
+ self,
+ scopes: list[str],
+ assertion: str | Callable[[], str],
+ *,
+ username: Optional[str] = None,
+ user_object_id: Optional[str] = None,
+ claims_challenge: Optional[None] = None,
+ **kwargs: Any,
) -> dict[str, Any]: ...
class SystemAssignedManagedIdentity:
diff --git a/uv.lock b/uv.lock
index 9cd6c7189..6594e1637 100644
--- a/uv.lock
+++ b/uv.lock
@@ -9,6 +9,7 @@ resolution-markers = [
[manifest]
members = [
"a2a",
+ "agent365",
"ai-agentframework",
"botbuilder",
"cards",
@@ -134,6 +135,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1c/58/1878b9ac4db703f40c687129c0bccdbf88c94d557b64f0172f3c4e954558/agent_framework_openai-1.1.0-py3-none-any.whl", hash = "sha256:8fbcdb87fbc3fb6aa6f3d781a61a2c48fbc308d2ee8165454f94e53e026a787b", size = 50280, upload-time = "2026-04-21T06:20:11.864Z" },
]
+[[package]]
+name = "agent365"
+version = "0.1.0"
+source = { virtual = "examples/agent365" }
+dependencies = [
+ { name = "dotenv" },
+ { name = "microsoft-teams-apps" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "dotenv", specifier = ">=0.9.9" },
+ { name = "microsoft-teams-apps", editable = "packages/apps" },
+]
+
[[package]]
name = "ai-agentframework"
version = "0.1.0"
@@ -1834,13 +1850,13 @@ test = [
[package.metadata]
requires-dist = [
- { name = "cryptography", specifier = ">=3.4.0" },
+ { name = "cryptography", specifier = ">=48.0.1" },
{ name = "dependency-injector", specifier = ">=4.48.1" },
{ name = "fastapi", specifier = ">=0.115.13" },
{ name = "microsoft-teams-api", editable = "packages/api" },
{ name = "microsoft-teams-common", editable = "packages/common" },
{ name = "microsoft-teams-graph", marker = "extra == 'graph'", editable = "packages/graph" },
- { name = "msal", specifier = ">=1.33.0" },
+ { name = "msal", specifier = ">=1.37.0" },
{ name = "pydantic-settings", specifier = ">=2.11.0" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.12.0" },
{ name = "python-dotenv", specifier = ">=1.0.0" },
@@ -1953,16 +1969,16 @@ dev = [
[[package]]
name = "msal"
-version = "1.34.0"
+version = "1.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "requests" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/cf/0e/c857c46d653e104019a84f22d4494f2119b4fe9f896c92b4b864b3b045cc/msal-1.34.0.tar.gz", hash = "sha256:76ba83b716ea5a6d75b0279c0ac353a0e05b820ca1f6682c0eb7f45190c43c2f", size = 153961, upload-time = "2025-09-22T23:05:48.989Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/9a/99/d840198ecf6e8057bbc937f129ae940404485d736cda73253bbff9537f01/msal-1.37.0.tar.gz", hash = "sha256:1b1672a33ee467c1d70b341bb16cafd51bb3c817147a95b93263794b03971bec", size = 182444, upload-time = "2026-05-29T19:49:05.561Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/c2/dc/18d48843499e278538890dc709e9ee3dea8375f8be8e82682851df1b48b5/msal-1.34.0-py3-none-any.whl", hash = "sha256:f669b1644e4950115da7a176441b0e13ec2975c29528d8b9e81316023676d6e1", size = 116987, upload-time = "2025-09-22T23:05:47.294Z" },
+ { url = "https://files.pythonhosted.org/packages/94/b0/d807279f4b55d16d1f120d5ac4344c6e39b56732e2a224d40bded7fd67ad/msal-1.37.0-py3-none-any.whl", hash = "sha256:dd17e95a7c71bce75e8108113438ba7c4a086b3bcad4f57a8c09b7af3d753c2d", size = 123725, upload-time = "2026-05-29T19:49:04.335Z" },
]
[[package]]