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/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..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( @@ -387,3 +389,65 @@ 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.""" + 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.""" + 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.""" + 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 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)