From c8ad8963172710248197b92748eab6b435109025 Mon Sep 17 00:00:00 2001 From: lilydu Date: Fri, 3 Apr 2026 15:31:25 -0700 Subject: [PATCH 1/3] feat: add ActivitySender.delete() with targeted support --- .../microsoft_teams/apps/activity_sender.py | 16 +++++++ packages/apps/tests/test_activity_sender.py | 47 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py index 78d02ae8..ad80d05d 100644 --- a/packages/apps/src/microsoft_teams/apps/activity_sender.py +++ b/packages/apps/src/microsoft_teams/apps/activity_sender.py @@ -76,6 +76,22 @@ async def send(self, activity: ActivityParams, ref: ConversationReference) -> Se res = await activities.create(activity) return SentActivity.merge(activity, res) + async def delete(self, activity_id: str, ref: ConversationReference, targeted: bool = False) -> None: + """ + Delete an activity from a conversation. + + Args: + activity_id: The ID of the activity to delete + ref: The conversation reference + targeted: If True, deletes a targeted (ephemeral) activity + """ + api = ApiClient(service_url=ref.service_url, options=self._client) + activities = api.conversations.activities(ref.conversation.id) + if targeted: + await activities.delete_targeted(activity_id) + else: + await activities.delete(activity_id) + def create_stream(self, ref: ConversationReference) -> StreamerProtocol: """ Create a new activity stream for real-time updates. diff --git a/packages/apps/tests/test_activity_sender.py b/packages/apps/tests/test_activity_sender.py index 384edbb9..ce3890f7 100644 --- a/packages/apps/tests/test_activity_sender.py +++ b/packages/apps/tests/test_activity_sender.py @@ -170,3 +170,50 @@ async def test_update_non_targeted_message_calls_update(self, sender, conversati mock_activities.update.assert_called_once_with("existing-msg-id", activity) mock_activities.update_targeted.assert_not_called() + + @pytest.mark.asyncio + async def test_delete_calls_delete(self, sender, conversation_ref): + """Test that delete() calls the underlying delete method.""" + mock_activities = MagicMock() + mock_activities.delete = AsyncMock(return_value=None) + + with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: + mock_api = MagicMock() + mock_api.conversations.activities.return_value = mock_activities + mock_api_client.return_value = mock_api + + await sender.delete("msg-123", conversation_ref) + + mock_activities.delete.assert_called_once_with("msg-123") + mock_activities.delete_targeted.assert_not_called() + + @pytest.mark.asyncio + async def test_delete_targeted_calls_delete_targeted(self, sender, conversation_ref): + """Test that delete() with targeted=True calls delete_targeted.""" + mock_activities = MagicMock() + mock_activities.delete_targeted = AsyncMock(return_value=None) + + with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: + mock_api = MagicMock() + mock_api.conversations.activities.return_value = mock_activities + mock_api_client.return_value = mock_api + + await sender.delete("msg-123", conversation_ref, targeted=True) + + mock_activities.delete_targeted.assert_called_once_with("msg-123") + mock_activities.delete.assert_not_called() + + @pytest.mark.asyncio + async def test_delete_uses_correct_conversation_id(self, sender, conversation_ref): + """Test that delete() uses conversation id from ref.""" + mock_activities = MagicMock() + mock_activities.delete = AsyncMock(return_value=None) + + with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client: + mock_api = MagicMock() + mock_api.conversations.activities.return_value = mock_activities + mock_api_client.return_value = mock_api + + await sender.delete("msg-123", conversation_ref) + + mock_api.conversations.activities.assert_called_once_with(conversation_ref.conversation.id) From 2901d6c9f52a517bde7eac20515713566a15d952 Mon Sep 17 00:00:00 2001 From: lilydu Date: Fri, 3 Apr 2026 15:38:37 -0700 Subject: [PATCH 2/3] feat: add ctx.delete() with proactive and targeted support --- .../apps/routing/activity_context.py | 31 ++++++++ packages/apps/tests/test_activity_context.py | 72 +++++++++++++++++++ 2 files changed, 103 insertions(+) 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 9f273a19..b102dbf5 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -6,6 +6,7 @@ import base64 import json import logging +import warnings from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Optional, TypeVar, cast @@ -34,6 +35,7 @@ from microsoft_teams.api.models.oauth import OAuthCard from microsoft_teams.cards import AdaptiveCard from microsoft_teams.common import Storage +from microsoft_teams.common.experimental import ExperimentalWarning from microsoft_teams.common.http.client_token import Token from ..activity_sender import ActivitySender @@ -186,6 +188,35 @@ async def send( res = await self._activity_sender.send(activity, ref) return res + async def delete( + self, + activity_id: str, + conversation_ref: Optional[ConversationReference] = None, + targeted: bool = False, + ) -> None: + """ + Delete an activity from the conversation. + + Args: + activity_id: The ID of the activity to delete + conversation_ref: Optional conversation reference to override the current one, + enabling proactive deletion from a different conversation + targeted: If True, deletes a targeted (ephemeral) activity + + .. warning:: Preview + The ``targeted`` parameter is in preview and may change or be + removed in future versions. Diagnostic: ExperimentalTeamsTargeted + """ + if targeted: + warnings.warn( + "The targeted parameter of delete is in preview and may change " + "or be removed in future versions. Diagnostic: ExperimentalTeamsTargeted", + ExperimentalWarning, + stacklevel=2, + ) + ref = conversation_ref or self.conversation_ref + await self._activity_sender.delete(activity_id, ref, targeted=targeted) + async def reply(self, input: str | ActivityParams) -> SentActivity: """Send a reply to the activity.""" activity = MessageActivityInput(text=input) if isinstance(input, str) else input diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 96fdfbd9..00a01c72 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -387,3 +387,75 @@ async def test_sign_out_logs_error_and_does_not_raise_on_failure(self) -> None: mock_log_error.assert_called_once() logged_message = mock_log_error.call_args[0][0] assert "Failed to sign out user" in logged_message + + +class TestActivityContextDelete: + """Tests for ActivityContext.delete().""" + + @pytest.mark.asyncio + async def test_delete_delegates_to_sender(self) -> None: + """delete() calls ActivitySender.delete with current conversation_ref.""" + ctx, mock_sender = _create_activity_context() + mock_sender.delete = AsyncMock(return_value=None) + + await ctx.delete("msg-123") + + mock_sender.delete.assert_called_once_with("msg-123", ctx.conversation_ref, targeted=False) + + @pytest.mark.asyncio + async def test_delete_with_custom_conversation_ref(self) -> None: + """delete() uses provided conversation_ref for proactive deletion.""" + from microsoft_teams.api import ConversationReference + + ctx, mock_sender = _create_activity_context() + mock_sender.delete = AsyncMock(return_value=None) + + custom_ref = MagicMock(spec=ConversationReference) + await ctx.delete("msg-123", conversation_ref=custom_ref) + + mock_sender.delete.assert_called_once_with("msg-123", custom_ref, targeted=False) + + @pytest.mark.asyncio + async def test_delete_targeted_passes_flag(self) -> None: + """delete() with targeted=True passes targeted=True to sender.""" + ctx, mock_sender = _create_activity_context() + mock_sender.delete = AsyncMock(return_value=None) + + await ctx.delete("msg-123", targeted=True) + + mock_sender.delete.assert_called_once_with("msg-123", ctx.conversation_ref, targeted=True) + + @pytest.mark.asyncio + async def test_delete_targeted_emits_experimental_warning(self) -> None: + """delete() with targeted=True emits ExperimentalWarning.""" + import warnings + + from microsoft_teams.common.experimental import ExperimentalWarning + + ctx, mock_sender = _create_activity_context() + mock_sender.delete = AsyncMock(return_value=None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await ctx.delete("msg-123", targeted=True) + + experimental = [w for w in caught if issubclass(w.category, ExperimentalWarning)] + assert len(experimental) == 1 + assert "ExperimentalTeamsTargeted" in str(experimental[0].message) + + @pytest.mark.asyncio + async def test_delete_non_targeted_no_warning(self) -> None: + """delete() without targeted=True does not emit ExperimentalWarning.""" + import warnings + + from microsoft_teams.common.experimental import ExperimentalWarning + + ctx, mock_sender = _create_activity_context() + mock_sender.delete = AsyncMock(return_value=None) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await ctx.delete("msg-123") + + experimental = [w for w in caught if issubclass(w.category, ExperimentalWarning)] + assert len(experimental) == 0 From decdb3902e022f20e33bcdae03f764ab61bfa793 Mon Sep 17 00:00:00 2001 From: lilydu Date: Fri, 3 Apr 2026 15:45:28 -0700 Subject: [PATCH 3/3] fix imports in test to top level --- packages/apps/tests/test_activity_context.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 00a01c72..6606d19d 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -5,12 +5,14 @@ # pyright: basic +import warnings from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest -from microsoft_teams.api import Account, MessageActivityInput, SentActivity +from microsoft_teams.api import Account, ConversationReference, MessageActivityInput, SentActivity from microsoft_teams.apps.routing.activity_context import ActivityContext +from microsoft_teams.common.experimental import ExperimentalWarning def _create_activity_context( @@ -405,8 +407,6 @@ async def test_delete_delegates_to_sender(self) -> None: @pytest.mark.asyncio async def test_delete_with_custom_conversation_ref(self) -> None: """delete() uses provided conversation_ref for proactive deletion.""" - from microsoft_teams.api import ConversationReference - ctx, mock_sender = _create_activity_context() mock_sender.delete = AsyncMock(return_value=None) @@ -428,10 +428,6 @@ async def test_delete_targeted_passes_flag(self) -> None: @pytest.mark.asyncio async def test_delete_targeted_emits_experimental_warning(self) -> None: """delete() with targeted=True emits ExperimentalWarning.""" - import warnings - - from microsoft_teams.common.experimental import ExperimentalWarning - ctx, mock_sender = _create_activity_context() mock_sender.delete = AsyncMock(return_value=None) @@ -446,10 +442,6 @@ async def test_delete_targeted_emits_experimental_warning(self) -> None: @pytest.mark.asyncio async def test_delete_non_targeted_no_warning(self) -> None: """delete() without targeted=True does not emit ExperimentalWarning.""" - import warnings - - from microsoft_teams.common.experimental import ExperimentalWarning - ctx, mock_sender = _create_activity_context() mock_sender.delete = AsyncMock(return_value=None)