diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py index c00e5ad7..cb0b7055 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/channel_account.py @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Any +from typing import Any, Optional from pydantic import ConfigDict from .agents_model import AgentsModel @@ -27,11 +27,11 @@ class ChannelAccount(AgentsModel): id: NonEmptyString = None name: str = None - aad_object_id: NonEmptyString = None - role: NonEmptyString = None - agentic_user_id: NonEmptyString = None - agentic_app_id: NonEmptyString = None - tenant_id: NonEmptyString = None + aad_object_id: Optional[NonEmptyString] = None + role: Optional[NonEmptyString] = None + agentic_user_id: Optional[NonEmptyString] = None + agentic_app_id: Optional[NonEmptyString] = None + tenant_id: Optional[NonEmptyString] = None @property def properties(self) -> dict[str, Any]: diff --git a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_account.py b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_account.py index 80a8bfa2..93813146 100644 --- a/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_account.py +++ b/libraries/microsoft-agents-activity/microsoft_agents/activity/conversation_account.py @@ -31,11 +31,11 @@ class ConversationAccount(AgentsModel): :type properties: object """ - is_group: bool = None + is_group: Optional[bool] = None conversation_type: NonEmptyString = None id: NonEmptyString - name: NonEmptyString = None - aad_object_id: NonEmptyString = None - role: NonEmptyString = None + name: Optional[NonEmptyString] = None + aad_object_id: Optional[NonEmptyString] = None + role: Optional[NonEmptyString] = None tenant_id: Optional[NonEmptyString] = None properties: object = None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py index af30b409..5cd8f089 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/authorization/claims_identity.py @@ -78,4 +78,8 @@ def get_token_audience(self) -> str: :return: The token audience. """ - return f"app://{self.get_outgoing_app_id()}" + return ( + f"app://{self.get_outgoing_app_id()}" + if self.is_agent_claim() + else AuthenticationConstants.AGENTS_SDK_SCOPE + ) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py index 2b4e6969..60cf0e79 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/channel_service_adapter.py @@ -232,7 +232,10 @@ async def continue_conversation_with_claims( :type audience: Optional[str] """ return await self.process_proactive( - claims_identity, continuation_activity, audience, callback + claims_identity, + continuation_activity, + audience or claims_identity.get_token_audience(), + callback, ) async def create_conversation( # pylint: disable=arguments-differ diff --git a/test_samples/app_style/README.md b/test_samples/app_style/README.md new file mode 100644 index 00000000..4c79ffb0 --- /dev/null +++ b/test_samples/app_style/README.md @@ -0,0 +1,44 @@ +# App-style samples + +This folder contains end-to-end samples that resemble production “app-style” experiences built on the Microsoft 365 Agents Python SDK. The new proactive messaging sample shows how to start a Microsoft Teams conversation or send a proactive message to an existing one. + +## Proactive messaging sample + +`proactive_messaging_agent.py` hosts two HTTP endpoints: + +- `POST /api/createconversation` – creates a new 1:1 Teams conversation with a user and optionally sends an initial message. +- `POST /api/sendmessage` – sends another proactive message to an existing conversation id. + +### Prerequisites + +1. Python 3.10 or later. +2. Install the Agents Python SDK packages (for example by running `pip install -e libraries/microsoft-agents-*`). +3. A published Copilot Studio agent configured for Teams with application (client) ID, client secret, and tenant ID. + +### Configure environment variables + +1. Copy `env.TEMPLATE` to `.env` if you have not already. +2. Populate the connection settings used to acquire tokens: + - `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTID` + - `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__CLIENTSECRET` + - `CONNECTIONS__SERVICE_CONNECTION__SETTINGS__TENANTID` +3. Add the proactive messaging settings from the template (bot id, agent id, tenant id, service URL, etc.). Optionally set `PROACTIVEMESSAGING__USERAADOBJECTID` to provide a default recipient. +4. Leave `TOKENVALIDATION__ENABLED=false` for local testing. Set it to `true` and supply a valid bearer token when calling the APIs if you need auth checks. + +### Run the sample + +```pwsh +python proactive_messaging_agent.py +``` + +The server listens on `http://localhost:5199` by default. Use the following helper commands to exercise the endpoints (replace the sample values with your own IDs): + +```pwsh +# Create a new conversation (returns conversationId) +Invoke-RestMethod -Method POST -Uri "http://localhost:5199/api/createconversation" -ContentType "application/json" -Body (@{ Message = "Hello from proactive sample"; UserAadObjectId = "00000000-0000-0000-0000-000000000123" } | ConvertTo-Json) + +# Send another proactive message +Invoke-RestMethod -Method POST -Uri "http://localhost:5199/api/sendmessage" -ContentType "application/json" -Body (@{ ConversationId = ""; Message = "Second proactive ping" } | ConvertTo-Json) +``` + +When `TOKENVALIDATION__ENABLED` is `true`, add an `Authorization: Bearer ` header to each call. The proactive endpoints will respond with JSON payloads describing success or validation errors. diff --git a/test_samples/app_style/echo_proactive_agent.py b/test_samples/app_style/echo_proactive_agent.py new file mode 100644 index 00000000..ed91ded4 --- /dev/null +++ b/test_samples/app_style/echo_proactive_agent.py @@ -0,0 +1,285 @@ +"""Echo skill sample that mirrors the Copilot Studio EchoSkill agent.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from os import environ, path +from typing import Any, Dict, Optional + +from aiohttp import web +from dotenv import load_dotenv + +from microsoft_agents.activity import ( + load_configuration_from_env, + Activity, + ConversationReference, + EndOfConversationCodes, +) +from microsoft_agents.authentication.msal import MsalConnectionManager +from microsoft_agents.hosting.aiohttp import CloudAdapter, start_agent_process +from microsoft_agents.hosting.core import ( + AgentApplication, + Authorization, + MemoryStorage, + MessageFactory, + TurnContext, + TurnState, +) +from microsoft_agents.hosting.core.authorization import ClaimsIdentity +from microsoft_agents.hosting.core.storage import StoreItem + + +@dataclass +class SendActivityRequest: + """Request payload used to resume conversations proactively.""" + + conversation_id: str + message: str + + @classmethod + def from_dict(cls, payload: Dict[str, Any]) -> "SendActivityRequest": + conversation_id = payload.get("conversationId") or payload.get( + "conversation_id" + ) + if not conversation_id: + raise ValueError("conversationId is required.") + + message = payload.get("message") + if not message: + raise ValueError("message is required.") + + return cls(conversation_id=conversation_id, message=message) + + +@dataclass +class ConversationReferenceRecord(StoreItem): + """Persistent envelope for a conversation reference and associated identity.""" + + claims: dict[str, str] + is_authenticated: bool + authentication_type: Optional[str] + reference: ConversationReference + + @staticmethod + def get_key(conversation_id: str) -> str: + return f"conversationreferences/{conversation_id}" + + @property + def key(self) -> str: + return self.get_key(self.reference.conversation.id) + + @classmethod + def from_context(cls, context: TurnContext) -> "ConversationReferenceRecord": + identity = context.identity or ClaimsIdentity({}, False) + reference = context.activity.get_conversation_reference() + return cls( + claims=dict(identity.claims), + is_authenticated=identity.is_authenticated, + authentication_type=identity.authentication_type, + reference=reference, + ) + + def to_identity(self) -> ClaimsIdentity: + return ClaimsIdentity( + claims=dict(self.claims), + is_authenticated=self.is_authenticated, + authentication_type=self.authentication_type, + ) + + def store_item_to_json(self) -> Dict[str, Any]: + return { + "claims": dict(self.claims), + "is_authenticated": self.is_authenticated, + "authentication_type": self.authentication_type, + "reference": self.reference.model_dump(mode="json"), + } + + @staticmethod + def from_json_to_store_item( + json_data: Dict[str, Any], + ) -> "ConversationReferenceRecord": + reference_payload = json_data.get("reference") + if not reference_payload: + raise ValueError("Conversation reference payload is missing.") + + reference = ConversationReference.model_validate( + reference_payload, strict=False + ) + return ConversationReferenceRecord( + claims=json_data.get("claims", {}), + is_authenticated=json_data.get("is_authenticated", False), + authentication_type=json_data.get("authentication_type"), + reference=reference, + ) + + +load_dotenv(path.join(path.dirname(__file__), ".env")) +agents_sdk_config = load_configuration_from_env(environ) + +storage = MemoryStorage() +connection_manager = MsalConnectionManager(**agents_sdk_config) +adapter = CloudAdapter(connection_manager=connection_manager) +authorization = Authorization(storage, connection_manager, **agents_sdk_config) +AGENT_APP = AgentApplication[TurnState]( + storage=storage, + adapter=adapter, + authorization=authorization, + **agents_sdk_config.get("AGENTAPPLICATION", {}), +) + + +@AGENT_APP.activity("message") +async def on_message(context: TurnContext, state: TurnState) -> None: + text = context.activity.text or "" + if "end" == text: + await context.send_activity("(EchoSkill) Ending conversation...") + end_activity = Activity.create_end_of_conversation_activity() + end_activity.code = EndOfConversationCodes.completed_successfully + await context.send_activity(end_activity) + await state.conversation.delete(context) + conversation = context.activity.conversation + if conversation and conversation.id: + await state.conversation._storage.delete( + [ConversationReferenceRecord.get_key(conversation.id)] + ) + return + + logging.info( + f"(EchoSkill) ConversationReference to save: {context.activity.get_conversation_reference().model_dump(mode='json', exclude_unset=True, by_alias=True)} with message: {text}" + ) + record = ConversationReferenceRecord.from_context(context) + await state.conversation._storage.write({record.key: record}) + + await context.send_activity(MessageFactory.text(f"(EchoSkill): {text}")) + + +class EchoSkillService: + def __init__( + self, + storage: MemoryStorage, + adapter: CloudAdapter, + ) -> None: + self._storage = storage + self._adapter = adapter + + async def send_activity_to_conversation( + self, conversation_id: str, message: str + ) -> bool: + if not conversation_id: + return False + + key = ConversationReferenceRecord.get_key(conversation_id) + items: Dict[str, ConversationReferenceRecord] = await self._storage.read( + [key], target_cls=ConversationReferenceRecord + ) + record = items.get(key) + if not record: + return False + + continuation_activity = record.reference.get_continuation_activity() + + async def _callback(turn_context: TurnContext) -> None: + await turn_context.send_activity(message) + + await self._adapter.continue_conversation_with_claims( + record.to_identity(), continuation_activity, _callback + ) + return True + + +async def _read_optional_json(request: web.Request) -> Dict[str, Any]: + if request.content_length in (0, None): + return {} + try: + return await request.json() + except json.JSONDecodeError: + return {} + + +def create_app() -> web.Application: + """Create and configure the aiohttp application hosting the sample.""" + + load_dotenv(path.join(path.dirname(__file__), ".env")) + + echo_service = EchoSkillService(storage, adapter) + global SERVICE_INSTANCE + SERVICE_INSTANCE = echo_service + + app = web.Application() + app["adapter"] = adapter + app["agent_app"] = AGENT_APP + app["echo_service"] = echo_service + agent_config = connection_manager.get_default_connection_configuration() + if not agent_config: + raise ValueError("SERVICE_CONNECTION settings are missing.") + app["agent_configuration"] = agent_config + + app.router.add_get("/", _handle_root) + app.router.add_post("/api/messages", _agent_entry_point) + app.router.add_post("/api/sendactivity", _handle_send_activity) + + return app + + +async def _handle_root(request: web.Request) -> web.Response: + return web.json_response({"status": "ready", "sample": "echo-skill"}) + + +async def _agent_entry_point(request: web.Request) -> web.Response: + agent_app: AgentApplication = request.app["agent_app"] + adapter: CloudAdapter = request.app["adapter"] + response = await start_agent_process(request, agent_app, adapter) + return response or web.Response(status=202) + + +async def _handle_send_activity(request: web.Request) -> web.Response: + service: EchoSkillService = request.app["echo_service"] + payload = await _read_optional_json(request) + + try: + send_request = SendActivityRequest.from_dict(payload) + except ValueError as exc: + return web.json_response( + { + "status": "Error", + "error": {"code": "Validation", "message": str(exc)}, + }, + status=400, + ) + + success = await service.send_activity_to_conversation( + send_request.conversation_id, send_request.message + ) + + if not success: + return web.json_response( + { + "status": "Error", + "error": { + "code": "NotFound", + "message": "Conversation reference not found.", + }, + }, + status=404, + ) + + return web.json_response( + {"status": "Delivered", "conversationId": send_request.conversation_id}, + status=202, + ) + + +def main() -> None: + logging.basicConfig(level=logging.INFO) + app = create_app() + + host = environ.get("HOST", "localhost") + port = int(environ.get("PORT", "3978")) + + web.run_app(app, host=host, port=port) + + +if __name__ == "__main__": + main() diff --git a/test_samples/app_style/emtpy_agent.py b/test_samples/app_style/emtpy_agent.py index dad36347..c68083c9 100644 --- a/test_samples/app_style/emtpy_agent.py +++ b/test_samples/app_style/emtpy_agent.py @@ -1,6 +1,13 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import logging +from os import environ, path +from dotenv import load_dotenv +from microsoft_agents.activity import load_configuration_from_env +from microsoft_agents.authentication.msal import ( + MsalConnectionManager, +) from microsoft_agents.hosting.core import ( AgentApplication, TurnState, @@ -8,10 +15,23 @@ MemoryStorage, ) from microsoft_agents.hosting.aiohttp import CloudAdapter +from microsoft_agents.hosting.core.app.oauth.authorization import Authorization from shared import start_server -AGENT_APP = AgentApplication[TurnState](storage=MemoryStorage(), adapter=CloudAdapter()) +logging.basicConfig(level=logging.INFO) +load_dotenv(path.join(path.dirname(__file__), ".env")) + +agents_sdk_config = load_configuration_from_env(environ) + +STORAGE = MemoryStorage() +CONNECTION_MANAGER = MsalConnectionManager(**agents_sdk_config) +ADAPTER = CloudAdapter(connection_manager=CONNECTION_MANAGER) +AUTHORIZATION = Authorization(STORAGE, CONNECTION_MANAGER, **agents_sdk_config) + +AGENT_APP = AgentApplication[TurnState]( + storage=STORAGE, adapter=ADAPTER, authorization=AUTHORIZATION, **agents_sdk_config +) async def _help(context: TurnContext, _state: TurnState): @@ -33,6 +53,9 @@ async def on_message(context: TurnContext, _): if __name__ == "__main__": try: - start_server(AGENT_APP, None) + start_server( + agent_application=AGENT_APP, + auth_configuration=CONNECTION_MANAGER.get_default_connection_configuration(), + ) except Exception as error: raise error diff --git a/test_samples/app_style/env.TEMPLATE b/test_samples/app_style/env.TEMPLATE index f9adb9ac..89af6af9 100644 --- a/test_samples/app_style/env.TEMPLATE +++ b/test_samples/app_style/env.TEMPLATE @@ -16,4 +16,14 @@ AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__MCS__SETTINGS__OBOCONNECTIONNAME= COPILOTSTUDIOAGENT__ENVIRONMENTID=environment-id COPILOTSTUDIOAGENT__SCHEMANAME=schema-name COPILOTSTUDIOAGENT__TENANTID=tenant-id -COPILOTSTUDIOAGENT__AGENTAPPID=agent-app-id \ No newline at end of file +COPILOTSTUDIOAGENT__AGENTAPPID=agent-app-id + +# Proactive messaging sample settings +PROACTIVEMESSAGING__BOTID=28:teams-app-id +PROACTIVEMESSAGING__AGENTID=teams-app-id +PROACTIVEMESSAGING__TENANTID=tenant-id +PROACTIVEMESSAGING__SCOPE=https://api.botframework.com/.default +PROACTIVEMESSAGING__CHANNELID=msteams +PROACTIVEMESSAGING__SERVICEURL=https://smba.trafficmanager.net/teams/ +# Optional default user (if not supplied per request) +PROACTIVEMESSAGING__USERAADOBJECTID= diff --git a/test_samples/fastapi/shared/__init__.py b/test_samples/fastapi/shared/__init__.py index be7909b4..3d625f30 100644 --- a/test_samples/fastapi/shared/__init__.py +++ b/test_samples/fastapi/shared/__init__.py @@ -11,5 +11,5 @@ "get_pull_requests", "get_user_info", "create_profile_card", - "create_pr_card" + "create_pr_card", ]