From 0a0425033af120b799760485989d38dcd197c9fd Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:14:27 +0000 Subject: [PATCH 01/10] Add support for Targeted Messages --- .../api/clients/conversation/activity.py | 31 +++++-- .../api/clients/conversation/client.py | 12 +-- .../tests/unit/test_conversation_client.py | 88 +++++++++++++++++++ packages/apps/src/microsoft_teams/apps/app.py | 19 +++- .../src/microsoft_teams/apps/app_process.py | 3 +- .../apps/contexts/function_context.py | 14 ++- .../src/microsoft_teams/apps/http_plugin.py | 16 +++- .../microsoft_teams/apps/plugins/sender.py | 4 +- .../apps/routing/activity_context.py | 18 ++-- packages/apps/tests/test_function_context.py | 9 +- .../devtools/devtools_plugin.py | 6 +- 11 files changed, 188 insertions(+), 32 deletions(-) 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 08ac96b7..daac8c6b 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -35,20 +35,24 @@ def __init__( super().__init__(http_client, api_client_settings) self.service_url = service_url - async def create(self, conversation_id: str, activity: ActivityParams) -> SentActivity: + async def create(self, conversation_id: str, activity: ActivityParams, is_targeted: bool = False) -> SentActivity: """ Create a new activity in a conversation. Args: conversation_id: The ID of the conversation activity: The activity to create + is_targeted: When True, sends the message privately to the recipient specified in activity.recipient Returns: The created activity """ + url = f"{self.service_url}/v3/conversations/{conversation_id}/activities" + if is_targeted: + url += "?isTargetedActivity=true" response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities", + url, json=activity.model_dump(by_alias=True, exclude_none=True), ) @@ -58,7 +62,9 @@ async def create(self, conversation_id: str, activity: ActivityParams) -> SentAc id = response.json().get("id", "DO_NOT_USE_PLACEHOLDER_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, is_targeted: bool = False + ) -> SentActivity: """ Update an existing activity in a conversation. @@ -66,18 +72,25 @@ async def update(self, conversation_id: str, activity_id: str, activity: Activit conversation_id: The ID of the conversation activity_id: The ID of the activity to update activity: The updated activity data + is_targeted: When True, sends the message privately to the recipient specified in activity.recipient Returns: The updated activity """ + url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" + if is_targeted: + url += "?isTargetedActivity=true" + response = await self.http.put( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", + url, json=activity.model_dump(by_alias=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, is_targeted: bool = False + ) -> SentActivity: """ Reply to an activity in a conversation. @@ -85,14 +98,20 @@ async def reply(self, conversation_id: str, activity_id: str, activity: Activity conversation_id: The ID of the conversation activity_id: The ID of the activity to reply to activity: The reply activity + is_targeted: When True, sends the message privately to the recipient specified in activity.recipient Returns: The created reply activity """ activity_json = activity.model_dump(by_alias=True) activity_json["replyToId"] = activity_id + + url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" + if is_targeted: + url += "?isTargetedActivity=true" + response = await self.http.post( - f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}", + url, json=activity_json, ) id = response.json()["id"] 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 e3c2842e..3afad67a 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -30,14 +30,14 @@ def __init__(self, client: "ConversationClient", conversation_id: str) -> None: class ActivityOperations(ConversationOperations): """Operations for managing activities in a conversation.""" - async def create(self, activity: ActivityParams): - return await self._client.activities_client.create(self._conversation_id, activity) + async def create(self, activity: ActivityParams, is_targeted: bool = False): + return await self._client.activities_client.create(self._conversation_id, activity, is_targeted) - async def update(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.update(self._conversation_id, activity_id, activity) + async def update(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): + return await self._client.activities_client.update(self._conversation_id, activity_id, activity, is_targeted) - async def reply(self, activity_id: str, activity: ActivityParams): - return await self._client.activities_client.reply(self._conversation_id, activity_id, activity) + async def reply(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): + return await self._client.activities_client.reply(self._conversation_id, activity_id, activity, is_targeted) async def delete(self, activity_id: str): await self._client.activities_client.delete(self._conversation_id, activity_id) diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index b094c2d4..4397fe82 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -176,6 +176,94 @@ async def test_activity_get_members(self, mock_http_client): assert result is not None +@pytest.mark.unit +@pytest.mark.asyncio +class TestConversationActivityOperationsTargeted: + """Unit tests for ConversationClient activity operations with targeted messages.""" + + async def test_activity_create_targeted(self, mock_http_client, mock_activity): + """Test creating a targeted activity with is_targeted=True.""" + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + + conversation_id = "test_conversation_id" + activities = client.activities(conversation_id) + + # Add recipient to activity + from microsoft_teams.api import Account + + mock_activity.recipient = Account(id="user-123", name="Test User", role="user") + + result = await activities.create(mock_activity, is_targeted=True) + + assert result is not None + assert result.id is not None + + async def test_activity_update_targeted(self, mock_http_client, mock_activity): + """Test updating a targeted activity with is_targeted=True.""" + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + + conversation_id = "test_conversation_id" + activity_id = "test_activity_id" + activities = client.activities(conversation_id) + + # Add recipient to activity + from microsoft_teams.api import Account + + mock_activity.recipient = Account(id="user-123", name="Test User", role="user") + + result = await activities.update(activity_id, mock_activity, is_targeted=True) + + assert result is not None + assert result.id is not None + + async def test_activity_reply_targeted(self, mock_http_client, mock_activity): + """Test replying with a targeted activity with is_targeted=True.""" + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + + conversation_id = "test_conversation_id" + activity_id = "test_activity_id" + activities = client.activities(conversation_id) + + # Add recipient to activity + from microsoft_teams.api import Account + + mock_activity.recipient = Account(id="user-123", name="Test User", role="user") + + result = await activities.reply(activity_id, mock_activity, is_targeted=True) + + assert result is not None + assert result.id is not None + + async def test_activity_create_not_targeted(self, mock_http_client, mock_activity): + """Test creating a non-targeted activity with is_targeted=False.""" + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + + conversation_id = "test_conversation_id" + activities = client.activities(conversation_id) + + result = await activities.create(mock_activity, is_targeted=False) + + assert result is not None + assert result.id is not None + + async def test_activity_create_default_not_targeted(self, mock_http_client, mock_activity): + """Test creating an activity without is_targeted parameter defaults to False.""" + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + + conversation_id = "test_conversation_id" + activities = client.activities(conversation_id) + + result = await activities.create(mock_activity) + + assert result is not None + assert result.id is not None + + @pytest.mark.unit @pytest.mark.asyncio class TestConversationMemberOperations: diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 8cebefa0..68ae5814 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -259,8 +259,17 @@ async def on_http_stopped() -> None: self._events.emit("error", ErrorEvent(error, context={"method": "stop"})) raise - async def send(self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard): - """Send an activity proactively.""" + async def send( + self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard, is_targeted: bool = False + ): + """ + Send an activity proactively. + + Args: + conversation_id: The ID of the conversation to send to + activity: The activity to send (string, ActivityParams, or AdaptiveCard) + is_targeted: When True, sends the message privately to the recipient specified in the activity + """ if self.id is None: raise ValueError("app not started") @@ -279,7 +288,11 @@ async def send(self, conversation_id: str, activity: str | ActivityParams | Adap else: activity = activity - return await self.http.send(activity, conversation_ref) + # Validate recipient for targeted messages + if is_targeted and not activity.recipient: + raise ValueError("activity.recipient is required for targeted messages") + + return await self.http.send(activity, conversation_ref, is_targeted) def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None: """Add middleware to run on all activities.""" diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index 21d2df51..9c76b5ab 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -130,8 +130,9 @@ async def _build_context( async def updated_send( message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, + is_targeted: bool = False, ) -> SentActivity: - res = await send(message, conversation_ref) + res = await send(message, conversation_ref, is_targeted) if not self.event_manager: raise ValueError("EventManager was not initialized properly") 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 8766ba2e..d2e6b4d4 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -51,10 +51,16 @@ class FunctionContext(ClientContext, Generic[T]): data: T """The function payload.""" - async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[SentActivity]: + async def send( + self, activity: str | ActivityParams | AdaptiveCard, is_targeted: bool = False + ) -> Optional[SentActivity]: """ Send an activity to the current conversation. + Args: + activity: The activity to send (string, ActivityParams, or AdaptiveCard) + is_targeted: When True, sends the message privately to the recipient specified in the activity + Returns None if the conversation ID cannot be determined. """ if self.id is None or self.name is None: @@ -80,7 +86,11 @@ async def send(self, activity: str | ActivityParams | AdaptiveCard) -> Optional[ else: activity = activity - return await self.http.send(activity, conversation_ref) + # Validate recipient for targeted messages + if is_targeted and not activity.recipient: + raise ValueError("activity.recipient is required for targeted messages") + + return await self.http.send(activity, conversation_ref, is_targeted) 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_plugin.py b/packages/apps/src/microsoft_teams/apps/http_plugin.py index 21358f3d..72d36644 100644 --- a/packages/apps/src/microsoft_teams/apps/http_plugin.py +++ b/packages/apps/src/microsoft_teams/apps/http_plugin.py @@ -236,17 +236,27 @@ async def on_activity_response(self, event: PluginActivityResponseEvent) -> None """ self.logger.debug(f"Completing activity response for {event.activity.id}") - async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity: + async def send( + self, activity: ActivityParams, ref: ConversationReference, is_targeted: bool = False + ) -> SentActivity: api = ApiClient(service_url=ref.service_url, options=self.client.clone(ClientOptions(token=self.bot_token))) activity.from_ = ref.bot activity.conversation = ref.conversation + # Validate recipient is set for targeted messages + if is_targeted and not activity.recipient: + raise ValueError("activity.recipient is required for targeted messages") + + # Set conversation reference user to the activity recipient for targeted messages + if is_targeted: + ref.user = activity.recipient + if hasattr(activity, "id") and activity.id: - res = await api.conversations.activities(ref.conversation.id).update(activity.id, activity) + res = await api.conversations.activities(ref.conversation.id).update(activity.id, activity, is_targeted) return SentActivity.merge(activity, res) - res = await api.conversations.activities(ref.conversation.id).create(activity) + res = await api.conversations.activities(ref.conversation.id).create(activity, is_targeted) return SentActivity.merge(activity, res) async def _process_activity( diff --git a/packages/apps/src/microsoft_teams/apps/plugins/sender.py b/packages/apps/src/microsoft_teams/apps/plugins/sender.py index 31450b5a..70a9bea5 100644 --- a/packages/apps/src/microsoft_teams/apps/plugins/sender.py +++ b/packages/apps/src/microsoft_teams/apps/plugins/sender.py @@ -16,7 +16,9 @@ class Sender(PluginBase): """A plugin that can send activities""" @abstractmethod - async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity: + async def send( + self, activity: ActivityParams, ref: ConversationReference, is_targeted: bool = False + ) -> SentActivity: """Called by the App to send an activity""" pass 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 42109562..f28c105b 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -171,13 +171,15 @@ async def send( self, message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, + is_targeted: bool = False, ) -> SentActivity: """ Send a message to the conversation. Args: message: The message to send, can be a string, ActivityParams, or AdaptiveCard - conversation_id: Optional conversation ID to override the current conversation reference + conversation_ref: Optional conversation reference to override the current conversation reference + is_targeted: When True, sends the message privately to the recipient specified in the activity """ if isinstance(message, str): activity = MessageActivityInput(text=message) @@ -187,18 +189,24 @@ async def send( activity = message ref = conversation_ref or self.conversation_ref - res = await self._plugin.send(activity, ref) + res = await self._plugin.send(activity, ref, is_targeted) return res - async def reply(self, input: str | ActivityParams) -> SentActivity: - """Send a reply to the activity.""" + async def reply(self, input: str | ActivityParams, is_targeted: bool = False) -> SentActivity: + """ + Send a reply to the activity. + + Args: + input: The reply message, can be a string or ActivityParams + is_targeted: When True, sends the message privately to the recipient specified in the activity + """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input if isinstance(activity, MessageActivityInput): block_quote = self._build_block_quote_for_activity() if block_quote: activity.text = f"{block_quote}\n\n{activity.text}" if activity.text else block_quote activity.reply_to_id = self.activity.id - return await self.send(activity) + return await self.send(activity, is_targeted=is_targeted) async def next(self) -> None: """Call the next middleware in the chain.""" diff --git a/packages/apps/tests/test_function_context.py b/packages/apps/tests/test_function_context.py index 93ed9bd9..9209cab4 100644 --- a/packages/apps/tests/test_function_context.py +++ b/packages/apps/tests/test_function_context.py @@ -72,13 +72,14 @@ async def test_send_string_activity( result = await function_context.send("Hello world") assert result == "sent-activity" - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[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 is_targeted is False async def test_send_adaptive_card( self, @@ -91,10 +92,11 @@ async def test_send_adaptive_card( result = await function_context.send(card) assert result == "sent-activity" - sent_activity, conversation_ref = mock_http.send.call_args[0] + sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] assert sent_activity.attachments[0].content == card assert conversation_ref.conversation.id == "conv-123" + assert is_targeted is False async def test_send_creates_conversation_if_none( self, function_context: FunctionContext[Any], mock_http: Any @@ -110,6 +112,7 @@ async def test_send_creates_conversation_if_none( assert result == "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, conversation_ref, is_targeted = mock_http.send.call_args[0] assert sent_activity.text == "Hello new conversation" assert conversation_ref.conversation.id == "new-conv" + assert is_targeted is False diff --git a/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py b/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py index e905ed9d..4746572d 100644 --- a/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py +++ b/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py @@ -225,8 +225,10 @@ async def on_activity_response(self, event: PluginActivityResponseEvent): promise.set_result(event.response) del self.pending[event.activity.id] - async def send(self, activity: ActivityParams, ref: ConversationReference) -> SentActivity: - return await self.http.send(activity, ref) + async def send( + self, activity: ActivityParams, ref: ConversationReference, is_targeted: bool = False + ) -> SentActivity: + return await self.http.send(activity, ref, is_targeted) def create_stream(self, ref: ConversationReference) -> StreamerProtocol: return self.http.create_stream(ref) From 2576dd7e493fd2b4eee575658ec92cdda92e7ee8 Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:57:53 +0000 Subject: [PATCH 02/10] Add for Delete API and fixes --- .../api/clients/conversation/activity.py | 11 ++- .../api/clients/conversation/client.py | 4 +- .../tests/unit/test_conversation_client.py | 70 ++++++++++++++----- .../src/microsoft_teams/apps/http_plugin.py | 2 + .../apps/routing/activity_context.py | 4 ++ 5 files changed, 70 insertions(+), 21 deletions(-) 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 daac8c6b..4316a0f6 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -117,15 +117,22 @@ async def reply( 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, is_targeted: bool = False + ) -> None: """ Delete an activity from a conversation. Args: conversation_id: The ID of the conversation activity_id: The ID of the activity to delete + is_targeted: When True, deletes a targeted message privately """ - await self.http.delete(f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}") + url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" + if is_targeted: + url += "?isTargetedActivity=true" + + await self.http.delete(url) async def get_members(self, conversation_id: str, activity_id: str) -> List[Account]: """ 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 3afad67a..2e003972 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -39,8 +39,8 @@ async def update(self, activity_id: str, activity: ActivityParams, is_targeted: async def reply(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): return await self._client.activities_client.reply(self._conversation_id, activity_id, activity, is_targeted) - async def delete(self, activity_id: str): - await self._client.activities_client.delete(self._conversation_id, activity_id) + async def delete(self, activity_id: str, is_targeted: bool = False): + await self._client.activities_client.delete(self._conversation_id, activity_id, is_targeted) async def get_members(self, activity_id: str): return await self._client.activities_client.get_members(self._conversation_id, activity_id) diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index 4397fe82..7a6438af 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -176,72 +176,98 @@ async def test_activity_get_members(self, mock_http_client): assert result is not None +class URLCapturingTransport: + """Test helper to capture URLs from HTTP requests.""" + + def __init__(self, wrapped, captured_urls): + self._wrapped = wrapped + self._captured_urls = captured_urls + + async def handle_async_request(self, request): + self._captured_urls.append(str(request.url)) + return await self._wrapped.handle_async_request(request) + + @pytest.mark.unit @pytest.mark.asyncio class TestConversationActivityOperationsTargeted: """Unit tests for ConversationClient activity operations with targeted messages.""" async def test_activity_create_targeted(self, mock_http_client, mock_activity): - """Test creating a targeted activity with is_targeted=True.""" + """Test creating a targeted activity with is_targeted=True verifies URL query parameter.""" + from microsoft_teams.api import Account + service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) - conversation_id = "test_conversation_id" activities = client.activities(conversation_id) - # Add recipient to activity - from microsoft_teams.api import Account - mock_activity.recipient = Account(id="user-123", name="Test User", role="user") + # Capture URLs + captured_urls = [] + mock_http_client.http._transport = URLCapturingTransport( + mock_http_client.http._transport, captured_urls + ) + result = await activities.create(mock_activity, is_targeted=True) assert result is not None assert result.id is not None + assert any("isTargetedActivity=true" in url for url in captured_urls) async def test_activity_update_targeted(self, mock_http_client, mock_activity): - """Test updating a targeted activity with is_targeted=True.""" + """Test updating a targeted activity with is_targeted=True verifies URL query parameter.""" + from microsoft_teams.api import Account + service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) - conversation_id = "test_conversation_id" activity_id = "test_activity_id" activities = client.activities(conversation_id) - # Add recipient to activity - from microsoft_teams.api import Account - mock_activity.recipient = Account(id="user-123", name="Test User", role="user") + # Capture URLs + captured_urls = [] + mock_http_client.http._transport = URLCapturingTransport( + mock_http_client.http._transport, captured_urls + ) + result = await activities.update(activity_id, mock_activity, is_targeted=True) assert result is not None assert result.id is not None + assert any("isTargetedActivity=true" in url for url in captured_urls) async def test_activity_reply_targeted(self, mock_http_client, mock_activity): - """Test replying with a targeted activity with is_targeted=True.""" + """Test replying with a targeted activity with is_targeted=True verifies URL query parameter.""" + from microsoft_teams.api import Account + service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) - conversation_id = "test_conversation_id" activity_id = "test_activity_id" activities = client.activities(conversation_id) - # Add recipient to activity - from microsoft_teams.api import Account - mock_activity.recipient = Account(id="user-123", name="Test User", role="user") + # Capture URLs + captured_urls = [] + mock_http_client.http._transport = URLCapturingTransport( + mock_http_client.http._transport, captured_urls + ) + result = await activities.reply(activity_id, mock_activity, is_targeted=True) assert result is not None assert result.id is not None + assert any("isTargetedActivity=true" in url for url in captured_urls) async def test_activity_create_not_targeted(self, mock_http_client, mock_activity): """Test creating a non-targeted activity with is_targeted=False.""" service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) - conversation_id = "test_conversation_id" activities = client.activities(conversation_id) @@ -254,7 +280,6 @@ async def test_activity_create_default_not_targeted(self, mock_http_client, mock """Test creating an activity without is_targeted parameter defaults to False.""" service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) - conversation_id = "test_conversation_id" activities = client.activities(conversation_id) @@ -263,6 +288,17 @@ async def test_activity_create_default_not_targeted(self, mock_http_client, mock assert result is not None assert result.id is not None + async def test_delete_targeted(self, mock_http_client): + """Test deleting an activity with is_targeted=True.""" + service_url = "https://test.service.url" + client = ConversationClient(service_url, mock_http_client) + conversation_id = "test_conversation_id" + activity_id = "test_activity_id" + activities = client.activities(conversation_id) + + # Should not raise an exception + await activities.delete(activity_id, is_targeted=True) + @pytest.mark.unit @pytest.mark.asyncio diff --git a/packages/apps/src/microsoft_teams/apps/http_plugin.py b/packages/apps/src/microsoft_teams/apps/http_plugin.py index 72d36644..e2ea3dd6 100644 --- a/packages/apps/src/microsoft_teams/apps/http_plugin.py +++ b/packages/apps/src/microsoft_teams/apps/http_plugin.py @@ -6,6 +6,7 @@ import asyncio import importlib.metadata from contextlib import AsyncExitStack, asynccontextmanager +from copy import copy from logging import Logger from pathlib import Path from types import SimpleNamespace @@ -250,6 +251,7 @@ async def send( # Set conversation reference user to the activity recipient for targeted messages if is_targeted: + ref = copy(ref) ref.user = activity.recipient if hasattr(activity, "id") and activity.id: diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index f28c105b..ce8b6cd4 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -188,6 +188,10 @@ async def send( else: activity = message + # Validate recipient is set for targeted messages + if is_targeted and getattr(activity, "recipient", None) is None: + raise ValueError("Targeted messages require 'activity.recipient' to be set.") + ref = conversation_ref or self.conversation_ref res = await self._plugin.send(activity, ref, is_targeted) return res From 1c7400a9b26af476b33f3ba3c2eb830b634b5561 Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Mon, 5 Jan 2026 15:02:43 +0000 Subject: [PATCH 03/10] Updates --- packages/api/tests/unit/test_conversation_client.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index 7a6438af..1e4fe4b1 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -5,6 +5,7 @@ # pyright: basic import pytest +from microsoft_teams.api import Account from microsoft_teams.api.clients.conversation import ConversationClient from microsoft_teams.api.clients.conversation.params import ( CreateConversationParams, @@ -195,8 +196,6 @@ class TestConversationActivityOperationsTargeted: async def test_activity_create_targeted(self, mock_http_client, mock_activity): """Test creating a targeted activity with is_targeted=True verifies URL query parameter.""" - from microsoft_teams.api import Account - service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) conversation_id = "test_conversation_id" @@ -218,8 +217,6 @@ async def test_activity_create_targeted(self, mock_http_client, mock_activity): async def test_activity_update_targeted(self, mock_http_client, mock_activity): """Test updating a targeted activity with is_targeted=True verifies URL query parameter.""" - from microsoft_teams.api import Account - service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) conversation_id = "test_conversation_id" @@ -242,8 +239,6 @@ async def test_activity_update_targeted(self, mock_http_client, mock_activity): async def test_activity_reply_targeted(self, mock_http_client, mock_activity): """Test replying with a targeted activity with is_targeted=True verifies URL query parameter.""" - from microsoft_teams.api import Account - service_url = "https://test.service.url" client = ConversationClient(service_url, mock_http_client) conversation_id = "test_conversation_id" From e6517904c3ef7535eb1e10872497df3431e24b8b Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:26:29 +0000 Subject: [PATCH 04/10] address comments --- .../api/clients/conversation/activity.py | 25 +++++++++---------- .../api/clients/conversation/client.py | 8 +++--- .../apps/contexts/function_context.py | 2 +- 3 files changed, 17 insertions(+), 18 deletions(-) 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 4316a0f6..0a82f8b4 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -35,7 +35,7 @@ def __init__( super().__init__(http_client, api_client_settings) self.service_url = service_url - async def create(self, conversation_id: str, activity: ActivityParams, is_targeted: bool = False) -> SentActivity: + async def create(self, conversation_id: str, activity: ActivityParams, *, is_targeted: bool = False) -> SentActivity: """ Create a new activity in a conversation. @@ -48,12 +48,12 @@ async def create(self, conversation_id: str, activity: ActivityParams, is_target The created activity """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities" - if is_targeted: - url += "?isTargetedActivity=true" + params = {"isTargetedActivity": "true"} if is_targeted else None response = await self.http.post( url, json=activity.model_dump(by_alias=True, exclude_none=True), + params=params, ) # Note: Typing activities (non-streaming) always produce empty responses. @@ -63,7 +63,7 @@ async def create(self, conversation_id: str, activity: ActivityParams, is_target return SentActivity(id=id, activity_params=activity) async def update( - self, conversation_id: str, activity_id: str, activity: ActivityParams, is_targeted: bool = False + self, conversation_id: str, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False ) -> SentActivity: """ Update an existing activity in a conversation. @@ -78,18 +78,18 @@ async def update( The updated activity """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - if is_targeted: - url += "?isTargetedActivity=true" + params = {"isTargetedActivity": "true"} if is_targeted else None response = await self.http.put( url, json=activity.model_dump(by_alias=True), + params=params, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) async def reply( - self, conversation_id: str, activity_id: str, activity: ActivityParams, is_targeted: bool = False + self, conversation_id: str, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False ) -> SentActivity: """ Reply to an activity in a conversation. @@ -107,18 +107,18 @@ async def reply( activity_json["replyToId"] = activity_id url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - if is_targeted: - url += "?isTargetedActivity=true" + params = {"isTargetedActivity": "true"} if is_targeted else None response = await self.http.post( url, json=activity_json, + params=params, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) async def delete( - self, conversation_id: str, activity_id: str, is_targeted: bool = False + self, conversation_id: str, activity_id: str, *, is_targeted: bool = False ) -> None: """ Delete an activity from a conversation. @@ -129,10 +129,9 @@ async def delete( is_targeted: When True, deletes a targeted message privately """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - if is_targeted: - url += "?isTargetedActivity=true" + params = {"isTargetedActivity": "true"} if is_targeted else None - await self.http.delete(url) + await self.http.delete(url, params=params) async def get_members(self, conversation_id: str, activity_id: str) -> List[Account]: """ 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 2e003972..51ca30be 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -31,16 +31,16 @@ class ActivityOperations(ConversationOperations): """Operations for managing activities in a conversation.""" async def create(self, activity: ActivityParams, is_targeted: bool = False): - return await self._client.activities_client.create(self._conversation_id, activity, is_targeted) + return await self._client.activities_client.create(self._conversation_id, activity, is_targeted=is_targeted) async def update(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): - return await self._client.activities_client.update(self._conversation_id, activity_id, activity, is_targeted) + return await self._client.activities_client.update(self._conversation_id, activity_id, activity, is_targeted=is_targeted) async def reply(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): - return await self._client.activities_client.reply(self._conversation_id, activity_id, activity, is_targeted) + return await self._client.activities_client.reply(self._conversation_id, activity_id, activity, is_targeted=is_targeted) async def delete(self, activity_id: str, is_targeted: bool = False): - await self._client.activities_client.delete(self._conversation_id, activity_id, is_targeted) + await self._client.activities_client.delete(self._conversation_id, activity_id, is_targeted=is_targeted) async def get_members(self, activity_id: str): return await self._client.activities_client.get_members(self._conversation_id, activity_id) 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 d2e6b4d4..ccd19c2a 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -52,7 +52,7 @@ class FunctionContext(ClientContext, Generic[T]): """The function payload.""" async def send( - self, activity: str | ActivityParams | AdaptiveCard, is_targeted: bool = False + self, activity: str | ActivityParams | AdaptiveCard, *, is_targeted: bool = False ) -> Optional[SentActivity]: """ Send an activity to the current conversation. From feb46cb964300602ef2d2643c901f406f110ba40 Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Wed, 7 Jan 2026 07:30:02 +0000 Subject: [PATCH 05/10] fix lint --- .../microsoft_teams/api/clients/conversation/activity.py | 4 +++- .../microsoft_teams/api/clients/conversation/client.py | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) 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 0a82f8b4..b9fb5022 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -35,7 +35,9 @@ def __init__( super().__init__(http_client, api_client_settings) self.service_url = service_url - async def create(self, conversation_id: str, activity: ActivityParams, *, is_targeted: bool = False) -> SentActivity: + async def create( + self, conversation_id: str, activity: ActivityParams, *, is_targeted: bool = False + ) -> SentActivity: """ Create a new activity in a conversation. 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 51ca30be..94b9728b 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -34,10 +34,14 @@ async def create(self, activity: ActivityParams, is_targeted: bool = False): return await self._client.activities_client.create(self._conversation_id, activity, is_targeted=is_targeted) async def update(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): - return await self._client.activities_client.update(self._conversation_id, activity_id, activity, is_targeted=is_targeted) + return await self._client.activities_client.update( + self._conversation_id, activity_id, activity, is_targeted=is_targeted + ) async def reply(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): - return await self._client.activities_client.reply(self._conversation_id, activity_id, activity, is_targeted=is_targeted) + return await self._client.activities_client.reply( + self._conversation_id, activity_id, activity, is_targeted=is_targeted + ) async def delete(self, activity_id: str, is_targeted: bool = False): await self._client.activities_client.delete(self._conversation_id, activity_id, is_targeted=is_targeted) From eab9808c999ec63838f58c7f00b56f87d1e7e42b Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:15:48 +0000 Subject: [PATCH 06/10] set recipient for targeted messages --- .../api/clients/conversation/activity.py | 24 ++++++++----- packages/apps/src/microsoft_teams/apps/app.py | 14 +++++--- .../src/microsoft_teams/apps/app_process.py | 4 +-- .../apps/contexts/function_context.py | 11 +++--- .../src/microsoft_teams/apps/http_plugin.py | 10 ------ .../apps/routing/activity_context.py | 18 +++++----- packages/apps/tests/test_app.py | 29 +++++++++++++++ packages/apps/tests/test_function_context.py | 35 +++++++++++++++++++ 8 files changed, 107 insertions(+), 38 deletions(-) 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 b9fb5022..c9117895 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -50,12 +50,14 @@ async def create( The created activity """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities" - params = {"isTargetedActivity": "true"} if is_targeted else None + params: dict[str, str] = {} + if is_targeted: + params["isTargetedActivity"] = "true" response = await self.http.post( url, json=activity.model_dump(by_alias=True, exclude_none=True), - params=params, + params=params or None, ) # Note: Typing activities (non-streaming) always produce empty responses. @@ -80,12 +82,14 @@ async def update( The updated activity """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - params = {"isTargetedActivity": "true"} if is_targeted else None + params: dict[str, str] = {} + if is_targeted: + params["isTargetedActivity"] = "true" response = await self.http.put( url, json=activity.model_dump(by_alias=True), - params=params, + params=params or None, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) @@ -109,12 +113,14 @@ async def reply( activity_json["replyToId"] = activity_id url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - params = {"isTargetedActivity": "true"} if is_targeted else None + params: dict[str, str] = {} + if is_targeted: + params["isTargetedActivity"] = "true" response = await self.http.post( url, json=activity_json, - params=params, + params=params or None, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) @@ -131,9 +137,11 @@ async def delete( is_targeted: When True, deletes a targeted message privately """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - params = {"isTargetedActivity": "true"} if is_targeted else None + params: dict[str, str] = {} + if is_targeted: + params["isTargetedActivity"] = "true" - await self.http.delete(url, params=params) + await self.http.delete(url, params=params or None) async def get_members(self, conversation_id: str, activity_id: str) -> List[Account]: """ diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index 68ae5814..fc46cd6f 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -260,7 +260,10 @@ async def on_http_stopped() -> None: raise async def send( - self, conversation_id: str, activity: str | ActivityParams | AdaptiveCard, is_targeted: bool = False + self, + conversation_id: str, + activity: str | ActivityParams | AdaptiveCard, + targeted_recipient_id: Optional[str] = None, ): """ Send an activity proactively. @@ -268,7 +271,7 @@ async def send( Args: conversation_id: The ID of the conversation to send to activity: The activity to send (string, ActivityParams, or AdaptiveCard) - is_targeted: When True, sends the message privately to the recipient specified in the activity + targeted_recipient_id: When provided, sends the message privately to the specified recipient ID """ if self.id is None: @@ -288,9 +291,10 @@ async def send( else: activity = activity - # Validate recipient for targeted messages - if is_targeted and not activity.recipient: - raise ValueError("activity.recipient is required for targeted messages") + # Handle targeted messages + is_targeted = targeted_recipient_id is not None + if is_targeted: + activity.recipient = Account(id=targeted_recipient_id) return await self.http.send(activity, conversation_ref, is_targeted) diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index 9c76b5ab..a158af5f 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -130,9 +130,9 @@ async def _build_context( async def updated_send( message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, - is_targeted: bool = False, + targeted_recipient_id: Optional[str] = None, ) -> SentActivity: - res = await send(message, conversation_ref, is_targeted) + res = await send(message, conversation_ref, targeted_recipient_id) if not self.event_manager: raise ValueError("EventManager was not initialized properly") 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 ccd19c2a..98cd975c 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -52,14 +52,14 @@ class FunctionContext(ClientContext, Generic[T]): """The function payload.""" async def send( - self, activity: str | ActivityParams | AdaptiveCard, *, is_targeted: bool = False + self, activity: str | ActivityParams | AdaptiveCard, *, targeted_recipient_id: Optional[str] = None ) -> Optional[SentActivity]: """ Send an activity to the current conversation. Args: activity: The activity to send (string, ActivityParams, or AdaptiveCard) - is_targeted: When True, sends the message privately to the recipient specified in the activity + targeted_recipient_id: When provided, sends the message privately to the specified recipient ID Returns None if the conversation ID cannot be determined. """ @@ -86,9 +86,10 @@ async def send( else: activity = activity - # Validate recipient for targeted messages - if is_targeted and not activity.recipient: - raise ValueError("activity.recipient is required for targeted messages") + # Handle targeted messages + is_targeted = targeted_recipient_id is not None + if is_targeted: + activity.recipient = Account(id=targeted_recipient_id) return await self.http.send(activity, conversation_ref, is_targeted) diff --git a/packages/apps/src/microsoft_teams/apps/http_plugin.py b/packages/apps/src/microsoft_teams/apps/http_plugin.py index e2ea3dd6..c3befee0 100644 --- a/packages/apps/src/microsoft_teams/apps/http_plugin.py +++ b/packages/apps/src/microsoft_teams/apps/http_plugin.py @@ -6,7 +6,6 @@ import asyncio import importlib.metadata from contextlib import AsyncExitStack, asynccontextmanager -from copy import copy from logging import Logger from pathlib import Path from types import SimpleNamespace @@ -245,15 +244,6 @@ async def send( activity.from_ = ref.bot activity.conversation = ref.conversation - # Validate recipient is set for targeted messages - if is_targeted and not activity.recipient: - raise ValueError("activity.recipient is required for targeted messages") - - # Set conversation reference user to the activity recipient for targeted messages - if is_targeted: - ref = copy(ref) - ref.user = activity.recipient - if hasattr(activity, "id") and activity.id: res = await api.conversations.activities(ref.conversation.id).update(activity.id, activity, is_targeted) return SentActivity.merge(activity, res) 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 ce8b6cd4..2d648357 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Optional, TypeVar, cast from microsoft_teams.api import ( + Account, ActivityBase, ActivityParams, ApiClient, @@ -171,7 +172,7 @@ async def send( self, message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, - is_targeted: bool = False, + targeted_recipient_id: Optional[str] = None, ) -> SentActivity: """ Send a message to the conversation. @@ -179,7 +180,7 @@ async def send( Args: message: The message to send, can be a string, ActivityParams, or AdaptiveCard conversation_ref: Optional conversation reference to override the current conversation reference - is_targeted: When True, sends the message privately to the recipient specified in the activity + targeted_recipient_id: When provided, sends the message privately to the specified recipient ID """ if isinstance(message, str): activity = MessageActivityInput(text=message) @@ -188,21 +189,22 @@ async def send( else: activity = message - # Validate recipient is set for targeted messages - if is_targeted and getattr(activity, "recipient", None) is None: - raise ValueError("Targeted messages require 'activity.recipient' to be set.") + # Handle targeted messages + is_targeted = targeted_recipient_id is not None + if is_targeted: + activity.recipient = Account(id=targeted_recipient_id) ref = conversation_ref or self.conversation_ref res = await self._plugin.send(activity, ref, is_targeted) return res - async def reply(self, input: str | ActivityParams, is_targeted: bool = False) -> SentActivity: + async def reply(self, input: str | ActivityParams, targeted_recipient_id: Optional[str] = None) -> SentActivity: """ Send a reply to the activity. Args: input: The reply message, can be a string or ActivityParams - is_targeted: When True, sends the message privately to the recipient specified in the activity + targeted_recipient_id: When provided, sends the message privately to the specified recipient ID """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input if isinstance(activity, MessageActivityInput): @@ -210,7 +212,7 @@ async def reply(self, input: str | ActivityParams, is_targeted: bool = False) -> if block_quote: activity.text = f"{block_quote}\n\n{activity.text}" if activity.text else block_quote activity.reply_to_id = self.activity.id - return await self.send(activity, is_targeted=is_targeted) + return await self.send(activity, targeted_recipient_id=targeted_recipient_id) async def next(self) -> None: """Call the next middleware in the chain.""" diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index 902359e0..0aa36da4 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -166,6 +166,35 @@ async def mock_on_stop(): await app_with_options.stop() assert not app_with_options.is_running + # Proactive Send Testing + + @pytest.mark.asyncio + async def test_send_with_targeted_recipient_id(self, app_with_options: App) -> None: + """Test proactive send with targeted_recipient_id sets recipient and is_targeted flag.""" + app_with_options.http.send = AsyncMock(return_value=MagicMock(id="sent-123")) + + await app_with_options.send("conv-123", "Private message", targeted_recipient_id="user-456") + + sent_activity, conversation_ref, is_targeted = app_with_options.http.send.call_args[0] + + assert sent_activity.text == "Private message" + assert sent_activity.recipient is not None + assert sent_activity.recipient.id == "user-456" + assert is_targeted is True + + @pytest.mark.asyncio + async def test_send_without_targeted_recipient_id(self, app_with_options: App) -> None: + """Test proactive send without targeted_recipient_id does not set recipient.""" + app_with_options.http.send = AsyncMock(return_value=MagicMock(id="sent-123")) + + await app_with_options.send("conv-123", "Public message") + + sent_activity, conversation_ref, is_targeted = app_with_options.http.send.call_args[0] + + assert sent_activity.text == "Public message" + assert sent_activity.recipient is None + assert is_targeted is False + # Event Testing - Focus on functional behavior @pytest.mark.asyncio diff --git a/packages/apps/tests/test_function_context.py b/packages/apps/tests/test_function_context.py index 9209cab4..d953ec44 100644 --- a/packages/apps/tests/test_function_context.py +++ b/packages/apps/tests/test_function_context.py @@ -116,3 +116,38 @@ async def test_send_creates_conversation_if_none( assert sent_activity.text == "Hello new conversation" assert conversation_ref.conversation.id == "new-conv" assert is_targeted is False + + async def test_send_with_targeted_recipient_id( + self, + function_context: FunctionContext[Any], + mock_http: Any, + ) -> None: + """Test sending a targeted message sets recipient and is_targeted flag.""" + + result = await function_context.send("Private message", targeted_recipient_id="user-123") + assert result == "sent-activity" + + sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] + + assert isinstance(sent_activity, MessageActivityInput) + assert sent_activity.text == "Private message" + assert sent_activity.recipient is not None + assert sent_activity.recipient.id == "user-123" + assert is_targeted is True + + async def test_send_without_targeted_recipient_id( + self, + function_context: FunctionContext[Any], + mock_http: Any, + ) -> None: + """Test sending a non-targeted message does not set recipient.""" + + result = await function_context.send("Public message") + assert result == "sent-activity" + + sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] + + assert isinstance(sent_activity, MessageActivityInput) + assert sent_activity.text == "Public message" + assert sent_activity.recipient is None + assert is_targeted is False From 16027544e4f748f959fe64230d23e66a61491c3e Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Tue, 13 Jan 2026 06:09:10 +0000 Subject: [PATCH 07/10] Address feedback --- .../api/clients/conversation/activity.py | 14 ++++++-------- .../api/clients/conversation/client.py | 8 ++++---- .../api/tests/unit/test_conversation_client.py | 12 +++--------- packages/apps/src/microsoft_teams/apps/app.py | 5 ++--- .../apps/src/microsoft_teams/apps/app_process.py | 3 ++- .../apps/contexts/function_context.py | 10 +++++++--- .../apps/src/microsoft_teams/apps/http_plugin.py | 8 +++++--- .../src/microsoft_teams/apps/plugins/sender.py | 2 +- .../apps/routing/activity_context.py | 5 +++-- packages/apps/tests/test_app.py | 6 ++++-- packages/apps/tests/test_function_context.py | 15 ++++++++++----- .../microsoft_teams/devtools/devtools_plugin.py | 4 ++-- 12 files changed, 49 insertions(+), 43 deletions(-) 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 c9117895..6a54fd43 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import List, Optional +from typing import Any, List, Optional from microsoft_teams.common.http import Client @@ -50,7 +50,7 @@ async def create( The created activity """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities" - params: dict[str, str] = {} + params: dict[str, Any] = {} if is_targeted: params["isTargetedActivity"] = "true" @@ -82,7 +82,7 @@ async def update( The updated activity """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - params: dict[str, str] = {} + params: dict[str, Any] = {} if is_targeted: params["isTargetedActivity"] = "true" @@ -113,7 +113,7 @@ async def reply( activity_json["replyToId"] = activity_id url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - params: dict[str, str] = {} + params: dict[str, Any] = {} if is_targeted: params["isTargetedActivity"] = "true" @@ -125,9 +125,7 @@ async def reply( id = response.json()["id"] return SentActivity(id=id, activity_params=activity) - async def delete( - self, conversation_id: str, activity_id: str, *, is_targeted: bool = False - ) -> None: + async def delete(self, conversation_id: str, activity_id: str, *, is_targeted: bool = False) -> None: """ Delete an activity from a conversation. @@ -137,7 +135,7 @@ async def delete( is_targeted: When True, deletes a targeted message privately """ url = f"{self.service_url}/v3/conversations/{conversation_id}/activities/{activity_id}" - params: dict[str, str] = {} + params: dict[str, Any] = {} if is_targeted: params["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 94b9728b..181cc196 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/client.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/client.py @@ -30,20 +30,20 @@ def __init__(self, client: "ConversationClient", conversation_id: str) -> None: class ActivityOperations(ConversationOperations): """Operations for managing activities in a conversation.""" - async def create(self, activity: ActivityParams, is_targeted: bool = False): + async def create(self, activity: ActivityParams, *, is_targeted: bool = False): return await self._client.activities_client.create(self._conversation_id, activity, is_targeted=is_targeted) - async def update(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): + async def update(self, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False): return await self._client.activities_client.update( self._conversation_id, activity_id, activity, is_targeted=is_targeted ) - async def reply(self, activity_id: str, activity: ActivityParams, is_targeted: bool = False): + async def reply(self, activity_id: str, activity: ActivityParams, *, is_targeted: bool = False): return await self._client.activities_client.reply( self._conversation_id, activity_id, activity, is_targeted=is_targeted ) - async def delete(self, activity_id: str, is_targeted: bool = False): + async def delete(self, activity_id: str, *, is_targeted: bool = False): await self._client.activities_client.delete(self._conversation_id, activity_id, is_targeted=is_targeted) async def get_members(self, activity_id: str): diff --git a/packages/api/tests/unit/test_conversation_client.py b/packages/api/tests/unit/test_conversation_client.py index 1e4fe4b1..3b9afe11 100644 --- a/packages/api/tests/unit/test_conversation_client.py +++ b/packages/api/tests/unit/test_conversation_client.py @@ -205,9 +205,7 @@ async def test_activity_create_targeted(self, mock_http_client, mock_activity): # Capture URLs captured_urls = [] - mock_http_client.http._transport = URLCapturingTransport( - mock_http_client.http._transport, captured_urls - ) + mock_http_client.http._transport = URLCapturingTransport(mock_http_client.http._transport, captured_urls) result = await activities.create(mock_activity, is_targeted=True) @@ -227,9 +225,7 @@ async def test_activity_update_targeted(self, mock_http_client, mock_activity): # Capture URLs captured_urls = [] - mock_http_client.http._transport = URLCapturingTransport( - mock_http_client.http._transport, captured_urls - ) + mock_http_client.http._transport = URLCapturingTransport(mock_http_client.http._transport, captured_urls) result = await activities.update(activity_id, mock_activity, is_targeted=True) @@ -249,9 +245,7 @@ async def test_activity_reply_targeted(self, mock_http_client, mock_activity): # Capture URLs captured_urls = [] - mock_http_client.http._transport = URLCapturingTransport( - mock_http_client.http._transport, captured_urls - ) + mock_http_client.http._transport = URLCapturingTransport(mock_http_client.http._transport, captured_urls) result = await activities.reply(activity_id, mock_activity, is_targeted=True) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index fc46cd6f..2831685a 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -292,11 +292,10 @@ async def send( activity = activity # Handle targeted messages - is_targeted = targeted_recipient_id is not None - if is_targeted: + if targeted_recipient_id: activity.recipient = Account(id=targeted_recipient_id) - return await self.http.send(activity, conversation_ref, is_targeted) + return await self.http.send(activity, conversation_ref, is_targeted=targeted_recipient_id is not None) def use(self, middleware: Callable[[ActivityContext[ActivityBase]], Awaitable[None]]) -> None: """Add middleware to run on all activities.""" diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index a158af5f..90d63124 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -130,9 +130,10 @@ async def _build_context( async def updated_send( message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, + *, targeted_recipient_id: Optional[str] = None, ) -> SentActivity: - res = await send(message, conversation_ref, targeted_recipient_id) + res = await send(message, conversation_ref, targeted_recipient_id=targeted_recipient_id) if not self.event_manager: raise ValueError("EventManager was not initialized properly") 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 98cd975c..08a16a28 100644 --- a/packages/apps/src/microsoft_teams/apps/contexts/function_context.py +++ b/packages/apps/src/microsoft_teams/apps/contexts/function_context.py @@ -87,11 +87,15 @@ async def send( activity = activity # Handle targeted messages - is_targeted = targeted_recipient_id is not None - if is_targeted: + if targeted_recipient_id: + if activity.recipient and activity.recipient.id != targeted_recipient_id: + raise ValueError( + f"Activity recipient ID '{activity.recipient.id}' does not match " + f"targeted_recipient_id '{targeted_recipient_id}'" + ) activity.recipient = Account(id=targeted_recipient_id) - return await self.http.send(activity, conversation_ref, is_targeted) + return await self.http.send(activity, conversation_ref, is_targeted=targeted_recipient_id is not None) 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_plugin.py b/packages/apps/src/microsoft_teams/apps/http_plugin.py index c3befee0..1df3ba01 100644 --- a/packages/apps/src/microsoft_teams/apps/http_plugin.py +++ b/packages/apps/src/microsoft_teams/apps/http_plugin.py @@ -237,7 +237,7 @@ async def on_activity_response(self, event: PluginActivityResponseEvent) -> None self.logger.debug(f"Completing activity response for {event.activity.id}") async def send( - self, activity: ActivityParams, ref: ConversationReference, is_targeted: bool = False + self, activity: ActivityParams, ref: ConversationReference, *, is_targeted: bool = False ) -> SentActivity: api = ApiClient(service_url=ref.service_url, options=self.client.clone(ClientOptions(token=self.bot_token))) @@ -245,10 +245,12 @@ async def send( activity.conversation = ref.conversation if hasattr(activity, "id") and activity.id: - res = await api.conversations.activities(ref.conversation.id).update(activity.id, activity, is_targeted) + res = await api.conversations.activities(ref.conversation.id).update( + activity.id, activity, is_targeted=is_targeted + ) return SentActivity.merge(activity, res) - res = await api.conversations.activities(ref.conversation.id).create(activity, is_targeted) + res = await api.conversations.activities(ref.conversation.id).create(activity, is_targeted=is_targeted) return SentActivity.merge(activity, res) async def _process_activity( diff --git a/packages/apps/src/microsoft_teams/apps/plugins/sender.py b/packages/apps/src/microsoft_teams/apps/plugins/sender.py index 70a9bea5..ca52dcdc 100644 --- a/packages/apps/src/microsoft_teams/apps/plugins/sender.py +++ b/packages/apps/src/microsoft_teams/apps/plugins/sender.py @@ -17,7 +17,7 @@ class Sender(PluginBase): @abstractmethod async def send( - self, activity: ActivityParams, ref: ConversationReference, is_targeted: bool = False + self, activity: ActivityParams, ref: ConversationReference, *, is_targeted: bool = False ) -> SentActivity: """Called by the App to send an activity""" pass 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 2d648357..2d109311 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -172,6 +172,7 @@ async def send( self, message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, + *, targeted_recipient_id: Optional[str] = None, ) -> SentActivity: """ @@ -195,10 +196,10 @@ async def send( activity.recipient = Account(id=targeted_recipient_id) ref = conversation_ref or self.conversation_ref - res = await self._plugin.send(activity, ref, is_targeted) + res = await self._plugin.send(activity, ref, is_targeted=is_targeted) return res - async def reply(self, input: str | ActivityParams, targeted_recipient_id: Optional[str] = None) -> SentActivity: + async def reply(self, input: str | ActivityParams, *, targeted_recipient_id: Optional[str] = None) -> SentActivity: """ Send a reply to the activity. diff --git a/packages/apps/tests/test_app.py b/packages/apps/tests/test_app.py index 0aa36da4..205e94ed 100644 --- a/packages/apps/tests/test_app.py +++ b/packages/apps/tests/test_app.py @@ -175,7 +175,8 @@ async def test_send_with_targeted_recipient_id(self, app_with_options: App) -> N await app_with_options.send("conv-123", "Private message", targeted_recipient_id="user-456") - sent_activity, conversation_ref, is_targeted = app_with_options.http.send.call_args[0] + sent_activity, conversation_ref = app_with_options.http.send.call_args[0] + is_targeted = app_with_options.http.send.call_args[1]["is_targeted"] assert sent_activity.text == "Private message" assert sent_activity.recipient is not None @@ -189,7 +190,8 @@ async def test_send_without_targeted_recipient_id(self, app_with_options: App) - await app_with_options.send("conv-123", "Public message") - sent_activity, conversation_ref, is_targeted = app_with_options.http.send.call_args[0] + sent_activity, conversation_ref = app_with_options.http.send.call_args[0] + is_targeted = app_with_options.http.send.call_args[1]["is_targeted"] assert sent_activity.text == "Public message" assert sent_activity.recipient is None diff --git a/packages/apps/tests/test_function_context.py b/packages/apps/tests/test_function_context.py index d953ec44..db6eaf68 100644 --- a/packages/apps/tests/test_function_context.py +++ b/packages/apps/tests/test_function_context.py @@ -72,7 +72,8 @@ async def test_send_string_activity( result = await function_context.send("Hello world") assert result == "sent-activity" - sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] + sent_activity, conversation_ref = mock_http.send.call_args[0] + is_targeted = mock_http.send.call_args[1]["is_targeted"] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.text == "Hello world" @@ -92,7 +93,8 @@ async def test_send_adaptive_card( result = await function_context.send(card) assert result == "sent-activity" - sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] + sent_activity, conversation_ref = mock_http.send.call_args[0] + is_targeted = mock_http.send.call_args[1]["is_targeted"] assert sent_activity.attachments[0].content == card assert conversation_ref.conversation.id == "conv-123" @@ -112,7 +114,8 @@ async def test_send_creates_conversation_if_none( assert result == "sent-new-conv" # Ensure conversation was created assert function_context.api.conversations.create.call_count == 1 # type: ignore - sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] + sent_activity, conversation_ref = mock_http.send.call_args[0] + is_targeted = mock_http.send.call_args[1]["is_targeted"] assert sent_activity.text == "Hello new conversation" assert conversation_ref.conversation.id == "new-conv" assert is_targeted is False @@ -127,7 +130,8 @@ async def test_send_with_targeted_recipient_id( result = await function_context.send("Private message", targeted_recipient_id="user-123") assert result == "sent-activity" - sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] + sent_activity, conversation_ref = mock_http.send.call_args[0] + is_targeted = mock_http.send.call_args[1]["is_targeted"] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.text == "Private message" @@ -145,7 +149,8 @@ async def test_send_without_targeted_recipient_id( result = await function_context.send("Public message") assert result == "sent-activity" - sent_activity, conversation_ref, is_targeted = mock_http.send.call_args[0] + sent_activity, conversation_ref = mock_http.send.call_args[0] + is_targeted = mock_http.send.call_args[1]["is_targeted"] assert isinstance(sent_activity, MessageActivityInput) assert sent_activity.text == "Public message" diff --git a/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py b/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py index 4746572d..707a9435 100644 --- a/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py +++ b/packages/devtools/src/microsoft_teams/devtools/devtools_plugin.py @@ -226,9 +226,9 @@ async def on_activity_response(self, event: PluginActivityResponseEvent): del self.pending[event.activity.id] async def send( - self, activity: ActivityParams, ref: ConversationReference, is_targeted: bool = False + self, activity: ActivityParams, ref: ConversationReference, *, is_targeted: bool = False ) -> SentActivity: - return await self.http.send(activity, ref, is_targeted) + return await self.http.send(activity, ref, is_targeted=is_targeted) def create_stream(self, ref: ConversationReference) -> StreamerProtocol: return self.http.create_stream(ref) From 00e9c900bf0c14e95f31462f171b455042f86e2f Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Tue, 13 Jan 2026 06:48:03 +0000 Subject: [PATCH 08/10] Add targeted messages example --- examples/targeted-messages/README.md | 40 +++++++ examples/targeted-messages/pyproject.toml | 14 +++ examples/targeted-messages/src/main.py | 100 ++++++++++++++++++ .../api/clients/conversation/activity.py | 8 +- uv.lock | 20 ++++ 5 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 examples/targeted-messages/README.md create mode 100644 examples/targeted-messages/pyproject.toml create mode 100644 examples/targeted-messages/src/main.py diff --git a/examples/targeted-messages/README.md b/examples/targeted-messages/README.md new file mode 100644 index 00000000..b2083a1f --- /dev/null +++ b/examples/targeted-messages/README.md @@ -0,0 +1,40 @@ +# Targeted Messages Example + +This example demonstrates how to send targeted (private) messages to specific users in Microsoft Teams group chats and channels. + +## Features + +- Send private messages visible only to a specific user +- Reply privately to messages +- Update and delete targeted messages +- Works in group chats and channels + +## Running the Example + +```bash +cd examples/targeted-messages +uv run python src/main.py +``` + +## Usage + +In a group chat or channel, mention the bot with one of these commands: + +- `@bot targeted` - Sends a private message only visible to you +- `@bot targeted-reply` - Replies privately to your message +- `@bot targeted-update` - Sends a private message, then updates it +- `@bot targeted-delete` - Sends a private message, then deletes it + +## How It Works + +Targeted messages use the `targeted_recipient_id` parameter to specify which user should see the message: + +```python +# Send a targeted message to the user who sent the activity +await ctx.send("This is private!", targeted_recipient_id=ctx.activity.from_.id) + +# Reply privately +await ctx.reply("Private reply!", targeted_recipient_id=ctx.activity.from_.id) +``` + +The message will appear in the conversation but only the targeted recipient can see it. diff --git a/examples/targeted-messages/pyproject.toml b/examples/targeted-messages/pyproject.toml new file mode 100644 index 00000000..63958e6f --- /dev/null +++ b/examples/targeted-messages/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "targeted-messages" +version = "0.1.0" +description = "Targeted messages example - demonstrates sending private messages to specific users" +readme = "README.md" +requires-python = ">=3.12,<3.14" +dependencies = [ + "microsoft-teams-apps", + "microsoft-teams-api", + "microsoft-teams-devtools" +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py new file mode 100644 index 00000000..693fee96 --- /dev/null +++ b/examples/targeted-messages/src/main.py @@ -0,0 +1,100 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. + +Targeted Messages Example + +This example demonstrates how to send targeted (private) messages +to specific users in Microsoft Teams group chats and channels. +""" + +import asyncio +import re + +from microsoft_teams.api import MessageActivity, MessageActivityInput +from microsoft_teams.apps import ActivityContext, App +from microsoft_teams.devtools import DevToolsPlugin + +app = App(plugins=[DevToolsPlugin()]) + + +@app.on_message_pattern(re.compile(r"^targeted-update$", re.IGNORECASE)) +async def on_targeted_update(ctx: ActivityContext[MessageActivity]) -> None: + """Send a targeted message and then update it.""" + # Send initial targeted message + sent = await ctx.send( + "⏳ This private message will be updated in 3 seconds...", + targeted_recipient_id=ctx.activity.from_.id, + ) + + # Wait 3 seconds + await asyncio.sleep(3) + + # Update the targeted message + update_activity = MessageActivityInput( + id=sent.id, + text="✅ Private message has been updated! Only you can see this.", + ) + update_activity.recipient = ctx.activity.from_ + + conversation_id = ctx.activity.conversation.id + await ctx.api.conversations.activities(conversation_id).update(sent.id, update_activity, is_targeted=True) + + +@app.on_message_pattern(re.compile(r"^targeted-delete$", re.IGNORECASE)) +async def on_targeted_delete(ctx: ActivityContext[MessageActivity]) -> None: + """Send a targeted message and then delete it.""" + # Send initial targeted message + sent = await ctx.send( + "⏳ This private message will be deleted in 3 seconds...", + targeted_recipient_id=ctx.activity.from_.id, + ) + + # Wait 3 seconds + await asyncio.sleep(3) + + # Delete the targeted message + conversation_id = ctx.activity.conversation.id + await ctx.api.conversations.activities(conversation_id).delete(sent.id, is_targeted=True) + + # Send confirmation (also targeted) + await ctx.send( + "🗑️ The previous private message has been deleted!", + targeted_recipient_id=ctx.activity.from_.id, + ) + + +@app.on_message_pattern(re.compile(r"^targeted-reply$", re.IGNORECASE)) +async def on_targeted_reply(ctx: ActivityContext[MessageActivity]) -> None: + """Reply with a targeted message that only the sender can see.""" + await ctx.reply( + "🔒 This private reply is only visible to you!", + targeted_recipient_id=ctx.activity.from_.id, + ) + + +@app.on_message_pattern(re.compile(r"^targeted$", re.IGNORECASE)) +async def on_targeted_message(ctx: ActivityContext[MessageActivity]) -> None: + """Send a targeted message that only the sender can see.""" + await ctx.send( + "👋 This is a private message only you can see!", + targeted_recipient_id=ctx.activity.from_.id, + ) + + +@app.on_message +async def on_message(ctx: ActivityContext[MessageActivity]) -> None: + """Default handler - show available commands.""" + await ctx.send( + "**Targeted Messages Example**\n\n" + "Available commands:\n" + "- `targeted` - Send a private message only you can see\n" + "- `targeted-reply` - Reply privately to your message\n" + "- `targeted-update` - Send a private message, then update it\n" + "- `targeted-delete` - Send a private message, then delete it\n\n" + "Try one of these commands in a group chat or channel!" + ) + + +if __name__ == "__main__": + asyncio.run(app.start()) 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 6a54fd43..f2e3ecd1 100644 --- a/packages/api/src/microsoft_teams/api/clients/conversation/activity.py +++ b/packages/api/src/microsoft_teams/api/clients/conversation/activity.py @@ -57,7 +57,7 @@ async def create( response = await self.http.post( url, json=activity.model_dump(by_alias=True, exclude_none=True), - params=params or None, + params=params, ) # Note: Typing activities (non-streaming) always produce empty responses. @@ -89,7 +89,7 @@ async def update( response = await self.http.put( url, json=activity.model_dump(by_alias=True), - params=params or None, + params=params, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) @@ -120,7 +120,7 @@ async def reply( response = await self.http.post( url, json=activity_json, - params=params or None, + params=params, ) id = response.json()["id"] return SentActivity(id=id, activity_params=activity) @@ -139,7 +139,7 @@ async def delete(self, conversation_id: str, activity_id: str, *, is_targeted: b if is_targeted: params["isTargetedActivity"] = "true" - await self.http.delete(url, params=params or None) + await self.http.delete(url, params=params) async def get_members(self, conversation_id: str, activity_id: str) -> List[Account]: """ diff --git a/uv.lock b/uv.lock index 9a30754a..d22d8f59 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,7 @@ members = [ "oauth", "stream", "tab", + "targeted-messages", ] [manifest.dependency-groups] @@ -2707,6 +2708,25 @@ requires-dist = [ { name = "microsoft-teams-devtools", editable = "packages/devtools" }, ] +[[package]] +name = "targeted-messages" +version = "0.1.0" +source = { virtual = "examples/targeted-messages" } +dependencies = [ + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, + { name = "microsoft-teams-devtools" }, + { name = "python-dotenv" }, +] + +[package.metadata] +requires-dist = [ + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, + { name = "microsoft-teams-devtools", editable = "packages/devtools" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, +] + [[package]] name = "text-unidecode" version = "1.3" From 3c805a754b98a4d1b23fe90612e70365df47d727 Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:46:11 +0000 Subject: [PATCH 09/10] pass isTargeted for Send/Reply --- examples/targeted-messages/pyproject.toml | 1 - examples/targeted-messages/src/main.py | 3 +-- .../apps/routing/activity_context.py | 27 +++++++++++++------ uv.lock | 4 --- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/examples/targeted-messages/pyproject.toml b/examples/targeted-messages/pyproject.toml index 63958e6f..113d97bd 100644 --- a/examples/targeted-messages/pyproject.toml +++ b/examples/targeted-messages/pyproject.toml @@ -7,7 +7,6 @@ requires-python = ">=3.12,<3.14" dependencies = [ "microsoft-teams-apps", "microsoft-teams-api", - "microsoft-teams-devtools" ] [tool.uv.sources] diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py index 693fee96..a049ff49 100644 --- a/examples/targeted-messages/src/main.py +++ b/examples/targeted-messages/src/main.py @@ -13,9 +13,8 @@ from microsoft_teams.api import MessageActivity, MessageActivityInput from microsoft_teams.apps import ActivityContext, App -from microsoft_teams.devtools import DevToolsPlugin -app = App(plugins=[DevToolsPlugin()]) +app = App() @app.on_message_pattern(re.compile(r"^targeted-update$", re.IGNORECASE)) 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 2d109311..be366581 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -173,6 +173,7 @@ async def send( message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, *, + is_targeted: bool = False, targeted_recipient_id: Optional[str] = None, ) -> SentActivity: """ @@ -181,7 +182,9 @@ async def send( Args: message: The message to send, can be a string, ActivityParams, or AdaptiveCard conversation_ref: Optional conversation reference to override the current conversation reference + is_targeted: When True, sends the message privately to the user who sent the activity targeted_recipient_id: When provided, sends the message privately to the specified recipient ID + (overrides is_targeted if both are provided) """ if isinstance(message, str): activity = MessageActivityInput(text=message) @@ -190,22 +193,30 @@ async def send( else: activity = message - # Handle targeted messages - is_targeted = targeted_recipient_id is not None - if is_targeted: - activity.recipient = Account(id=targeted_recipient_id) + # Handle targeted messages - targeted_recipient_id takes precedence over is_targeted + recipient_id = targeted_recipient_id or (self.activity.from_.id if is_targeted else None) + if recipient_id: + activity.recipient = Account(id=recipient_id) ref = conversation_ref or self.conversation_ref - res = await self._plugin.send(activity, ref, is_targeted=is_targeted) + res = await self._plugin.send(activity, ref, is_targeted=recipient_id is not None) return res - async def reply(self, input: str | ActivityParams, *, targeted_recipient_id: Optional[str] = None) -> SentActivity: + async def reply( + self, + input: str | ActivityParams, + *, + is_targeted: bool = False, + targeted_recipient_id: Optional[str] = None, + ) -> SentActivity: """ Send a reply to the activity. Args: input: The reply message, can be a string or ActivityParams - targeted_recipient_id: When provided, sends the message privately to the specified recipient ID + is_targeted: When True, sends the reply privately to the user who sent the activity + targeted_recipient_id: When provided, sends the reply privately to the specified recipient ID + (overrides is_targeted if both are provided) """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input if isinstance(activity, MessageActivityInput): @@ -213,7 +224,7 @@ async def reply(self, input: str | ActivityParams, *, targeted_recipient_id: Opt if block_quote: activity.text = f"{block_quote}\n\n{activity.text}" if activity.text else block_quote activity.reply_to_id = self.activity.id - return await self.send(activity, targeted_recipient_id=targeted_recipient_id) + return await self.send(activity, is_targeted=is_targeted, targeted_recipient_id=targeted_recipient_id) async def next(self) -> None: """Call the next middleware in the chain.""" diff --git a/uv.lock b/uv.lock index d22d8f59..c1f92fd0 100644 --- a/uv.lock +++ b/uv.lock @@ -2715,16 +2715,12 @@ source = { virtual = "examples/targeted-messages" } dependencies = [ { name = "microsoft-teams-api" }, { name = "microsoft-teams-apps" }, - { name = "microsoft-teams-devtools" }, - { name = "python-dotenv" }, ] [package.metadata] requires-dist = [ { name = "microsoft-teams-api", editable = "packages/api" }, { name = "microsoft-teams-apps", editable = "packages/apps" }, - { name = "microsoft-teams-devtools", editable = "packages/devtools" }, - { name = "python-dotenv", specifier = ">=1.0.0" }, ] [[package]] From 74648af260aef2160fa8f9bc31bbc902c10eb6ef Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Wed, 14 Jan 2026 10:52:32 +0000 Subject: [PATCH 10/10] update send --- packages/apps/src/microsoft_teams/apps/app_process.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/apps/src/microsoft_teams/apps/app_process.py b/packages/apps/src/microsoft_teams/apps/app_process.py index 90d63124..f3790f29 100644 --- a/packages/apps/src/microsoft_teams/apps/app_process.py +++ b/packages/apps/src/microsoft_teams/apps/app_process.py @@ -131,9 +131,12 @@ async def updated_send( message: str | ActivityParams | AdaptiveCard, conversation_ref: Optional[ConversationReference] = None, *, + is_targeted: bool = False, targeted_recipient_id: Optional[str] = None, ) -> SentActivity: - res = await send(message, conversation_ref, targeted_recipient_id=targeted_recipient_id) + res = await send( + message, conversation_ref, is_targeted=is_targeted, targeted_recipient_id=targeted_recipient_id + ) if not self.event_manager: raise ValueError("EventManager was not initialized properly")