Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/apps/src/microsoft_teams/apps/activity_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions packages/apps/src/microsoft_teams/apps/routing/activity_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion packages/apps/tests/test_activity_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
47 changes: 47 additions & 0 deletions packages/apps/tests/test_activity_sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading