From a3e168c5582cbeda5c3479bd854174eff12172b4 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Tue, 17 Mar 2026 17:11:11 -0700 Subject: [PATCH 01/16] Remove with_reply_to_id --- packages/api/src/microsoft_teams/api/models/activity.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/models/activity.py b/packages/api/src/microsoft_teams/api/models/activity.py index e021f779..805f16ba 100644 --- a/packages/api/src/microsoft_teams/api/models/activity.py +++ b/packages/api/src/microsoft_teams/api/models/activity.py @@ -126,11 +126,6 @@ def with_id(self, value: str) -> Self: self.id = value return self - def with_reply_to_id(self, value: str) -> Self: - """Set the reply_to_id.""" - self.reply_to_id = value - return self - def with_channel_id(self, value: ChannelID) -> Self: """Set the channel_id.""" self.channel_id = value @@ -179,6 +174,7 @@ def with_recipient(self, value: Account, is_targeted: Optional[bool] = None) -> ) return self + def with_service_url(self, value: str) -> Self: """Set the service_url.""" self.service_url = value From 1e9b596e2b1a41a5e05928cf6a6ed19e2ab31da5 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Tue, 17 Mar 2026 17:24:06 -0700 Subject: [PATCH 02/16] Quoted reply features and unit tests --- .../api/activities/message/message.py | 33 +++ .../api/models/entity/__init__.py | 3 + .../api/models/entity/entity.py | 2 + .../api/models/entity/quoted_reply_entity.py | 43 ++++ packages/api/tests/unit/test_activity.py | 2 - .../unit/test_quoted_replies_property.py | 107 +++++++++ .../tests/unit/test_quoted_reply_entity.py | 140 ++++++++++++ .../apps/routing/activity_context.py | 73 +++--- packages/apps/tests/test_quoted_reply.py | 214 ++++++++++++++++++ 9 files changed, 585 insertions(+), 32 deletions(-) create mode 100644 packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py create mode 100644 packages/api/tests/unit/test_quoted_replies_property.py create mode 100644 packages/api/tests/unit/test_quoted_reply_entity.py create mode 100644 packages/apps/tests/test_quoted_reply.py diff --git a/packages/api/src/microsoft_teams/api/activities/message/message.py b/packages/api/src/microsoft_teams/api/activities/message/message.py index 1b5111a0..5448a0fc 100644 --- a/packages/api/src/microsoft_teams/api/activities/message/message.py +++ b/packages/api/src/microsoft_teams/api/activities/message/message.py @@ -21,6 +21,8 @@ Importance, InputHint, MentionEntity, + QuotedReplyData, + QuotedReplyEntity, StreamInfoEntity, SuggestedActions, TextFormat, @@ -82,6 +84,15 @@ class MessageActivity(_MessageBase, ActivityBase): text: str = "" # pyright: ignore [reportGeneralTypeIssues, reportIncompatibleVariableOverride] """The text content of the message.""" + def get_quoted_messages(self) -> list[QuotedReplyEntity]: + """ + Get all quoted reply entities from the message. + + Returns: + List of quoted reply entities, empty if none + """ + return [e for e in (self.entities or []) if isinstance(e, QuotedReplyEntity)] + def is_recipient_mentioned(self) -> bool: """ Check if the recipient account is mentioned in the message. @@ -397,6 +408,28 @@ def add_stream_final(self) -> Self: return self.add_entity(stream_entity) + def add_quoted_reply(self, message_id: str, response: str | None = None) -> Self: + """ + Add a quotedReply entity for the given message ID and append a placeholder to text. + If response is provided, it is appended after the placeholder. + + Args: + message_id: The IC3 message ID of the message to quote + response: Optional response text to append after the placeholder + + Returns: + Self for method chaining + """ + if not self.entities: + self.entities = [] + self.entities.append( + QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id)) + ) + self.add_text(f'') + if response: + self.add_text(f" {response}") + return self + def with_recipient(self, value: Account, is_targeted: Optional[bool] = None) -> Self: """ Set the recipient. diff --git a/packages/api/src/microsoft_teams/api/models/entity/__init__.py b/packages/api/src/microsoft_teams/api/models/entity/__init__.py index 55fd6a10..473c46b7 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/entity/__init__.py @@ -18,6 +18,7 @@ from .mention_entity import MentionEntity from .message_entity import MessageEntity from .product_info_entity import ProductInfoEntity +from .quoted_reply_entity import QuotedReplyData, QuotedReplyEntity from .sensitive_usage_entity import SensitiveUsage, SensitiveUsageEntity, SensitiveUsagePattern from .stream_info_entity import StreamInfoEntity @@ -34,6 +35,8 @@ "MentionEntity", "MessageEntity", "ProductInfoEntity", + "QuotedReplyData", + "QuotedReplyEntity", "SensitiveUsageEntity", "SensitiveUsage", "SensitiveUsagePattern", diff --git a/packages/api/src/microsoft_teams/api/models/entity/entity.py b/packages/api/src/microsoft_teams/api/models/entity/entity.py index 5af75dd2..b89ce533 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/entity.py +++ b/packages/api/src/microsoft_teams/api/models/entity/entity.py @@ -11,6 +11,7 @@ from .mention_entity import MentionEntity from .message_entity import MessageEntity from .product_info_entity import ProductInfoEntity +from .quoted_reply_entity import QuotedReplyEntity from .sensitive_usage_entity import SensitiveUsageEntity from .stream_info_entity import StreamInfoEntity @@ -23,4 +24,5 @@ CitationEntity, SensitiveUsageEntity, ProductInfoEntity, + QuotedReplyEntity, ] diff --git a/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py new file mode 100644 index 00000000..4f05d310 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py @@ -0,0 +1,43 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Literal, Optional + +from ..custom_base_model import CustomBaseModel + + +class QuotedReplyData(CustomBaseModel): + """Data for a quoted reply entity""" + + message_id: str + "ID of the message being quoted" + + sender_id: Optional[str] = None + "ID of the sender of the quoted message" + + sender_name: Optional[str] = None + "Name of the sender of the quoted message" + + preview: Optional[str] = None + "Preview text of the quoted message" + + time: Optional[str] = None + "Timestamp of the quoted message" + + is_reply_deleted: Optional[bool] = None + "Whether the quoted reply has been deleted" + + validated_message_reference: Optional[bool] = None + "Whether the message reference has been validated" + + +class QuotedReplyEntity(CustomBaseModel): + """Entity containing quoted reply information""" + + type: Literal["quotedReply"] = "quotedReply" + "Type identifier for quoted reply" + + quoted_reply: QuotedReplyData + "The quoted reply data" diff --git a/packages/api/tests/unit/test_activity.py b/packages/api/tests/unit/test_activity.py index 0c7c4ea2..f121c959 100644 --- a/packages/api/tests/unit/test_activity.py +++ b/packages/api/tests/unit/test_activity.py @@ -75,7 +75,6 @@ def test_should_build( ) ) .with_recipient(bot) - .with_reply_to_id("3") .with_service_url("http://localhost") .with_timestamp(datetime.now()) .with_local_timestamp(datetime.now()) @@ -93,7 +92,6 @@ def test_should_build( conversation=chat, ) assert activity.recipient == bot - assert activity.reply_to_id == "3" assert activity.service_url == "http://localhost" assert activity.timestamp is not None assert activity.local_timestamp is not None diff --git a/packages/api/tests/unit/test_quoted_replies_property.py b/packages/api/tests/unit/test_quoted_replies_property.py new file mode 100644 index 00000000..7163c105 --- /dev/null +++ b/packages/api/tests/unit/test_quoted_replies_property.py @@ -0,0 +1,107 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from microsoft_teams.api.activities.message import MessageActivity +from microsoft_teams.api.models import Account, ConversationAccount, MentionEntity +from microsoft_teams.api.models.entity import QuotedReplyData, QuotedReplyEntity + + +class TestMessageActivityQuotedReplies: + """Tests for the get_quoted_messages property on MessageActivity""" + + def _create_message_activity(self, text: str = "Hello") -> MessageActivity: + """Create a basic MessageActivity for testing""" + return MessageActivity( + id="msg-123", + text=text, + from_=Account(id="user-1", name="User"), + conversation=ConversationAccount(id="conv-1", conversation_type="personal"), + recipient=Account(id="bot-1", name="Bot"), + ) + + def test_get_quoted_messages_returns_empty_when_no_entities(self): + """Test that get_quoted_messages returns empty list when no entities exist""" + activity = self._create_message_activity() + assert activity.entities is None + assert activity.get_quoted_messages() == [] + + def test_get_quoted_messages_returns_empty_when_no_quoted_reply_entities(self): + """Test that get_quoted_messages returns empty list when entities exist but none are QuotedReplyEntity""" + activity = self._create_message_activity() + activity.entities = [ + MentionEntity(mentioned=Account(id="user-1", name="User"), text="User"), + ] + assert activity.get_quoted_messages() == [] + + def test_get_quoted_messages_returns_matching_entities(self): + """Test that get_quoted_messages filters and returns only QuotedReplyEntity instances""" + activity = self._create_message_activity() + quoted = QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id="msg-456")) + activity.entities = [ + MentionEntity(mentioned=Account(id="user-1", name="User"), text="User"), + quoted, + ] + result = activity.get_quoted_messages() + assert len(result) == 1 + assert result[0] is quoted + assert result[0].quoted_reply.message_id == "msg-456" + + def test_get_quoted_messages_returns_multiple(self): + """Test that get_quoted_messages returns multiple QuotedReplyEntity instances""" + activity = self._create_message_activity() + q1 = QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id="msg-1")) + q2 = QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id="msg-2")) + activity.entities = [q1, q2] + result = activity.get_quoted_messages() + assert len(result) == 2 + assert result[0].quoted_reply.message_id == "msg-1" + assert result[1].quoted_reply.message_id == "msg-2" + + +class TestMessageActivityInputAddQuotedReply: + """Tests for the add_quoted_reply builder method on MessageActivityInput""" + + def test_add_quoted_reply_adds_entity_and_placeholder(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = MessageActivityInput().add_quoted_reply("msg-1") + assert len(msg.entities) == 1 + assert msg.entities[0].type == "quotedReply" + assert msg.entities[0].quoted_reply.message_id == "msg-1" + assert msg.text == '' + + def test_add_quoted_reply_with_response(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = MessageActivityInput().add_quoted_reply("msg-1", "my response") + assert msg.text == ' my response' + + def test_add_quoted_reply_multi_quote_interleaved(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = ( + MessageActivityInput() + .add_quoted_reply("msg-1", "response to first") + .add_quoted_reply("msg-2", "response to second") + ) + assert msg.text == ' response to first response to second' + assert len(msg.entities) == 2 + + def test_add_quoted_reply_grouped(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = ( + MessageActivityInput() + .add_quoted_reply("msg-1") + .add_quoted_reply("msg-2", "response to both") + ) + assert msg.text == ' response to both' + + def test_add_quoted_reply_chainable_with_add_text(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = MessageActivityInput().add_quoted_reply("msg-1").add_text(" manual text") + assert msg.text == ' manual text' diff --git a/packages/api/tests/unit/test_quoted_reply_entity.py b/packages/api/tests/unit/test_quoted_reply_entity.py new file mode 100644 index 00000000..26d9230a --- /dev/null +++ b/packages/api/tests/unit/test_quoted_reply_entity.py @@ -0,0 +1,140 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +from microsoft_teams.api.models.entity import QuotedReplyData, QuotedReplyEntity + + +class TestQuotedReplyData: + """Tests for QuotedReplyData model""" + + def test_minimal_creation(self): + """Test creating QuotedReplyData with only required fields""" + data = QuotedReplyData(message_id="msg-123") + assert data.message_id == "msg-123" + assert data.sender_id is None + assert data.sender_name is None + assert data.preview is None + assert data.time is None + assert data.is_reply_deleted is None + assert data.validated_message_reference is None + + def test_full_creation(self): + """Test creating QuotedReplyData with all fields""" + data = QuotedReplyData( + message_id="msg-123", + sender_id="user-456", + sender_name="Test User", + preview="Hello world", + time="2024-01-01T12:00:00Z", + is_reply_deleted=False, + validated_message_reference=True, + ) + assert data.message_id == "msg-123" + assert data.sender_id == "user-456" + assert data.sender_name == "Test User" + assert data.preview == "Hello world" + assert data.time == "2024-01-01T12:00:00Z" + assert data.is_reply_deleted is False + assert data.validated_message_reference is True + + def test_serialization_camel_case(self): + """Test that QuotedReplyData serializes to camelCase""" + data = QuotedReplyData( + message_id="msg-123", + sender_id="user-456", + sender_name="Test User", + is_reply_deleted=False, + validated_message_reference=True, + ) + serialized = data.model_dump(by_alias=True, exclude_none=True) + assert "messageId" in serialized + assert "senderId" in serialized + assert "senderName" in serialized + assert "isReplyDeleted" in serialized + assert "validatedMessageReference" in serialized + # Ensure snake_case keys are NOT present + assert "message_id" not in serialized + assert "sender_id" not in serialized + + def test_deserialization_from_camel_case(self): + """Test that QuotedReplyData deserializes from camelCase""" + data = QuotedReplyData.model_validate({ + "messageId": "msg-123", + "senderId": "user-456", + "senderName": "Test User", + "preview": "Hello world", + "time": "2024-01-01T12:00:00Z", + "isReplyDeleted": False, + "validatedMessageReference": True, + }) + assert data.message_id == "msg-123" + assert data.sender_id == "user-456" + assert data.sender_name == "Test User" + assert data.preview == "Hello world" + assert data.is_reply_deleted is False + assert data.validated_message_reference is True + + +class TestQuotedReplyEntity: + """Tests for QuotedReplyEntity model""" + + def test_creation_with_defaults(self): + """Test creating QuotedReplyEntity with default type""" + entity = QuotedReplyEntity( + quoted_reply=QuotedReplyData(message_id="msg-123") + ) + assert entity.type == "quotedReply" + assert entity.quoted_reply.message_id == "msg-123" + + def test_serialization_camel_case(self): + """Test that QuotedReplyEntity serializes to camelCase""" + entity = QuotedReplyEntity( + quoted_reply=QuotedReplyData(message_id="msg-123", sender_name="Alice") + ) + serialized = entity.model_dump(by_alias=True, exclude_none=True) + assert serialized["type"] == "quotedReply" + assert "quotedReply" in serialized + assert serialized["quotedReply"]["messageId"] == "msg-123" + assert serialized["quotedReply"]["senderName"] == "Alice" + + def test_deserialization_from_camel_case(self): + """Test that QuotedReplyEntity deserializes from camelCase""" + entity = QuotedReplyEntity.model_validate({ + "type": "quotedReply", + "quotedReply": { + "messageId": "msg-123", + "senderId": "user-456", + "senderName": "Test User", + }, + }) + assert entity.type == "quotedReply" + assert entity.quoted_reply.message_id == "msg-123" + assert entity.quoted_reply.sender_id == "user-456" + assert entity.quoted_reply.sender_name == "Test User" + + def test_round_trip_serialization(self): + """Test that serialization and deserialization are consistent""" + original = QuotedReplyEntity( + quoted_reply=QuotedReplyData( + message_id="msg-123", + sender_id="user-456", + sender_name="Test User", + preview="Hello world", + time="2024-01-01T12:00:00Z", + is_reply_deleted=False, + validated_message_reference=True, + ) + ) + serialized = original.model_dump(by_alias=True) + deserialized = QuotedReplyEntity.model_validate(serialized) + assert deserialized.type == original.type + assert deserialized.quoted_reply.message_id == original.quoted_reply.message_id + assert deserialized.quoted_reply.sender_id == original.quoted_reply.sender_id + assert deserialized.quoted_reply.sender_name == original.quoted_reply.sender_name + assert deserialized.quoted_reply.preview == original.quoted_reply.preview + assert deserialized.quoted_reply.time == original.quoted_reply.time + assert deserialized.quoted_reply.is_reply_deleted == original.quoted_reply.is_reply_deleted + assert deserialized.quoted_reply.validated_message_reference == original.quoted_reply.validated_message_reference 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 243463b3..fc84103a 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -31,6 +31,7 @@ OAuthCardAttachment, card_attachment, ) +from microsoft_teams.api.models.entity import QuotedReplyData, QuotedReplyEntity from microsoft_teams.api.models.oauth import OAuthCard from microsoft_teams.cards import AdaptiveCard from microsoft_teams.common import Storage @@ -187,13 +188,50 @@ async def send( return res async def reply(self, input: str | ActivityParams) -> SentActivity: - """Send a reply to the activity.""" + """ + Send a reply to the activity, automatically quoting the inbound message. + + Args: + input: The message to send, can be a string or ActivityParams + + Returns: + The sent 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 + if isinstance(activity, MessageActivityInput) and self.activity.id: + placeholder = f'' + if not activity.entities: + activity.entities = [] + activity.entities.append( + QuotedReplyEntity( + quoted_reply=QuotedReplyData(message_id=self.activity.id) + ) + ) + text = (activity.text or "").strip() + activity.text = f"{placeholder} {text}" if text else placeholder + return await self.send(activity) + + async def quote_reply(self, message_id: str, input: str | ActivityParams) -> SentActivity: + """ + Send a reply quoting a specific message by ID. + + Args: + message_id: The ID of the message to quote + input: The message to send, can be a string or ActivityParams + + Returns: + The sent activity + """ + activity = MessageActivityInput(text=input) if isinstance(input, str) else input + placeholder = f'' + if not activity.entities: + activity.entities = [] + activity.entities.append( + QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id)) + ) + text = (activity.text or "").strip() + activity.text = f"{placeholder} {text}" if text else placeholder return await self.send(activity) async def next(self) -> None: @@ -205,31 +243,6 @@ def set_next(self, handler: Callable[[], Awaitable[None]]) -> None: """Set the next handler in the middleware chain.""" self._next_handler = handler - def _build_block_quote_for_activity(self) -> Optional[str]: - if self.activity.type == "message" and hasattr(self.activity, "text"): - activity = cast(MessageActivityInput, self.activity) - max_length = 120 - text = activity.text or "" - truncated_text = f"{text[:max_length]}..." if len(text) > max_length else text - - activity_id = activity.id - from_id = activity.from_.id if activity.from_ else "" - from_name = activity.from_.name if activity.from_ else "" - - return ( - f'
' - f'{from_name}' - f'' - f'

{truncated_text}

' - f"
" - ) - else: - self.logger.debug( - "Skipping building blockquote for activity type: %s", - type(self.activity).__name__, - ) - return None - async def sign_in(self, options: Optional[SignInOptions] = None) -> Optional[str]: """ Initiate a sign-in flow for the user. diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py new file mode 100644 index 00000000..fae74f15 --- /dev/null +++ b/packages/apps/tests/test_quoted_reply.py @@ -0,0 +1,214 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from microsoft_teams.api import Account, MessageActivityInput, SentActivity +from microsoft_teams.api.models.entity import QuotedReplyEntity +from microsoft_teams.apps.routing.activity_context import ActivityContext + + +class TestActivityContextReply: + """Tests for ActivityContext.reply() with quoted reply entity stamping.""" + + def _create_activity_context( + self, + activity_id: str = "incoming-msg-123", + activity_type: str = "message", + activity_text: str = "Hello from user", + ) -> ActivityContext[Any]: + """Create an ActivityContext for testing with a mock activity sender.""" + mock_activity = MagicMock() + mock_activity.id = activity_id + mock_activity.type = activity_type + mock_activity.text = activity_text + mock_activity.from_ = Account(id="user-123", name="Test User") + + mock_activity_sender = MagicMock() + mock_activity_sender.send = AsyncMock( + return_value=SentActivity( + id="sent-activity-id", + activity_params=MessageActivityInput(text="sent"), + ) + ) + mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) + + mock_conversation_ref = MagicMock() + + return ActivityContext( + activity=mock_activity, + app_id="test-app-id", + logger=MagicMock(), + storage=MagicMock(), + api=MagicMock(), + user_token=None, + conversation_ref=mock_conversation_ref, + is_signed_in=False, + connection_name="test-connection", + activity_sender=mock_activity_sender, + app_token=MagicMock(), + ) + + @pytest.mark.asyncio + async def test_reply_stamps_quoted_reply_entity(self) -> None: + """Test that reply() adds a QuotedReplyEntity to the activity.""" + ctx = self._create_activity_context(activity_id="msg-abc") + await ctx.reply("Thanks!") + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.reply_to_id == "msg-abc" + assert sent_activity.entities is not None + assert len(sent_activity.entities) == 1 + entity = sent_activity.entities[0] + assert isinstance(entity, QuotedReplyEntity) + assert entity.quoted_reply.message_id == "msg-abc" + + @pytest.mark.asyncio + async def test_reply_prepends_placeholder(self) -> None: + """Test that reply() prepends the quoted placeholder to text.""" + ctx = self._create_activity_context(activity_id="msg-abc") + await ctx.reply("Thanks!") + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == ' Thanks!' + + @pytest.mark.asyncio + async def test_reply_with_empty_text(self) -> None: + """Test that reply() handles empty text correctly.""" + ctx = self._create_activity_context(activity_id="msg-abc") + activity = MessageActivityInput(text="") + await ctx.reply(activity) + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == '' + + @pytest.mark.asyncio + async def test_reply_with_none_text(self) -> None: + """Test that reply() handles None text correctly.""" + ctx = self._create_activity_context(activity_id="msg-abc") + activity = MessageActivityInput() + await ctx.reply(activity) + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == '' + + @pytest.mark.asyncio + async def test_reply_with_no_activity_id(self) -> None: + """Test that reply() does not stamp entity when activity.id is None.""" + ctx = self._create_activity_context(activity_id=None) + await ctx.reply("Thanks!") + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == "Thanks!" + assert sent_activity.entities is None + + @pytest.mark.asyncio + async def test_reply_preserves_existing_entities(self) -> None: + """Test that reply() preserves any pre-existing entities on the activity.""" + ctx = self._create_activity_context(activity_id="msg-abc") + activity = MessageActivityInput(text="Hello") + existing_entity = MagicMock() + activity.entities = [existing_entity] + + await ctx.reply(activity) + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.entities is not None + assert len(sent_activity.entities) == 2 + assert sent_activity.entities[0] is existing_entity + assert isinstance(sent_activity.entities[1], QuotedReplyEntity) + + @pytest.mark.asyncio + async def test_reply_with_activity_params(self) -> None: + """Test that reply() works with an ActivityParams input.""" + ctx = self._create_activity_context(activity_id="msg-abc") + activity = MessageActivityInput(text="Hello world") + await ctx.reply(activity) + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == ' Hello world' + assert sent_activity.reply_to_id == "msg-abc" + + +class TestActivityContextQuoteReply: + """Tests for ActivityContext.quote_reply() with arbitrary message ID.""" + + def _create_activity_context(self) -> ActivityContext[Any]: + """Create an ActivityContext for testing.""" + mock_activity = MagicMock() + mock_activity.id = "incoming-msg-123" + mock_activity.from_ = Account(id="user-123", name="Test User") + + mock_activity_sender = MagicMock() + mock_activity_sender.send = AsyncMock( + return_value=SentActivity( + id="sent-activity-id", + activity_params=MessageActivityInput(text="sent"), + ) + ) + mock_activity_sender.create_stream = MagicMock(return_value=MagicMock()) + + mock_conversation_ref = MagicMock() + + return ActivityContext( + activity=mock_activity, + app_id="test-app-id", + logger=MagicMock(), + storage=MagicMock(), + api=MagicMock(), + user_token=None, + conversation_ref=mock_conversation_ref, + is_signed_in=False, + connection_name="test-connection", + activity_sender=mock_activity_sender, + app_token=MagicMock(), + ) + + @pytest.mark.asyncio + async def test_quote_reply_stamps_entity_with_message_id(self) -> None: + """Test that quote_reply() stamps entity with the provided message ID.""" + ctx = self._create_activity_context() + await ctx.quote_reply("arbitrary-msg-id", "Quoting this!") + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.entities is not None + assert len(sent_activity.entities) == 1 + entity = sent_activity.entities[0] + assert isinstance(entity, QuotedReplyEntity) + assert entity.quoted_reply.message_id == "arbitrary-msg-id" + + @pytest.mark.asyncio + async def test_quote_reply_prepends_placeholder(self) -> None: + """Test that quote_reply() prepends the quoted placeholder.""" + ctx = self._create_activity_context() + await ctx.quote_reply("msg-xyz", "My reply text") + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == ' My reply text' + + @pytest.mark.asyncio + async def test_quote_reply_with_empty_text(self) -> None: + """Test that quote_reply() handles empty text correctly.""" + ctx = self._create_activity_context() + activity = MessageActivityInput(text="") + await ctx.quote_reply("msg-xyz", activity) + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == '' + + @pytest.mark.asyncio + async def test_quote_reply_with_activity_params(self) -> None: + """Test that quote_reply() works with an ActivityParams input.""" + ctx = self._create_activity_context() + activity = MessageActivityInput(text="Hello world") + await ctx.quote_reply("msg-xyz", activity) + + sent_activity = ctx._activity_sender.send.call_args[0][0] + assert sent_activity.text == ' Hello world' + assert sent_activity.entities is not None + assert len(sent_activity.entities) == 1 + assert isinstance(sent_activity.entities[0], QuotedReplyEntity) From babffd276a85bc95c56099ab71742f5cc95a4456 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 18 Mar 2026 11:14:20 -0700 Subject: [PATCH 03/16] Fix up --- .../api/activities/message/message.py | 6 +-- .../microsoft_teams/api/models/activity.py | 1 - .../unit/test_quoted_replies_property.py | 6 +-- .../tests/unit/test_quoted_reply_entity.py | 50 ++++++++++--------- .../apps/routing/activity_context.py | 10 +--- 5 files changed, 31 insertions(+), 42 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/activities/message/message.py b/packages/api/src/microsoft_teams/api/activities/message/message.py index 5448a0fc..55106d52 100644 --- a/packages/api/src/microsoft_teams/api/activities/message/message.py +++ b/packages/api/src/microsoft_teams/api/activities/message/message.py @@ -86,7 +86,7 @@ class MessageActivity(_MessageBase, ActivityBase): def get_quoted_messages(self) -> list[QuotedReplyEntity]: """ - Get all quoted reply entities from the message. + Get all quoted reply entities from this message. Returns: List of quoted reply entities, empty if none @@ -422,9 +422,7 @@ def add_quoted_reply(self, message_id: str, response: str | None = None) -> Self """ if not self.entities: self.entities = [] - self.entities.append( - QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id)) - ) + self.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) self.add_text(f'') if response: self.add_text(f" {response}") diff --git a/packages/api/src/microsoft_teams/api/models/activity.py b/packages/api/src/microsoft_teams/api/models/activity.py index 805f16ba..64ef24b4 100644 --- a/packages/api/src/microsoft_teams/api/models/activity.py +++ b/packages/api/src/microsoft_teams/api/models/activity.py @@ -174,7 +174,6 @@ def with_recipient(self, value: Account, is_targeted: Optional[bool] = None) -> ) return self - def with_service_url(self, value: str) -> Self: """Set the service_url.""" self.service_url = value diff --git a/packages/api/tests/unit/test_quoted_replies_property.py b/packages/api/tests/unit/test_quoted_replies_property.py index 7163c105..5ee2e781 100644 --- a/packages/api/tests/unit/test_quoted_replies_property.py +++ b/packages/api/tests/unit/test_quoted_replies_property.py @@ -93,11 +93,7 @@ def test_add_quoted_reply_multi_quote_interleaved(self): def test_add_quoted_reply_grouped(self): from microsoft_teams.api.activities.message import MessageActivityInput - msg = ( - MessageActivityInput() - .add_quoted_reply("msg-1") - .add_quoted_reply("msg-2", "response to both") - ) + msg = MessageActivityInput().add_quoted_reply("msg-1").add_quoted_reply("msg-2", "response to both") assert msg.text == ' response to both' def test_add_quoted_reply_chainable_with_add_text(self): diff --git a/packages/api/tests/unit/test_quoted_reply_entity.py b/packages/api/tests/unit/test_quoted_reply_entity.py index 26d9230a..2ac3403a 100644 --- a/packages/api/tests/unit/test_quoted_reply_entity.py +++ b/packages/api/tests/unit/test_quoted_reply_entity.py @@ -61,15 +61,17 @@ def test_serialization_camel_case(self): def test_deserialization_from_camel_case(self): """Test that QuotedReplyData deserializes from camelCase""" - data = QuotedReplyData.model_validate({ - "messageId": "msg-123", - "senderId": "user-456", - "senderName": "Test User", - "preview": "Hello world", - "time": "2024-01-01T12:00:00Z", - "isReplyDeleted": False, - "validatedMessageReference": True, - }) + data = QuotedReplyData.model_validate( + { + "messageId": "msg-123", + "senderId": "user-456", + "senderName": "Test User", + "preview": "Hello world", + "time": "2024-01-01T12:00:00Z", + "isReplyDeleted": False, + "validatedMessageReference": True, + } + ) assert data.message_id == "msg-123" assert data.sender_id == "user-456" assert data.sender_name == "Test User" @@ -83,17 +85,13 @@ class TestQuotedReplyEntity: def test_creation_with_defaults(self): """Test creating QuotedReplyEntity with default type""" - entity = QuotedReplyEntity( - quoted_reply=QuotedReplyData(message_id="msg-123") - ) + entity = QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id="msg-123")) assert entity.type == "quotedReply" assert entity.quoted_reply.message_id == "msg-123" def test_serialization_camel_case(self): """Test that QuotedReplyEntity serializes to camelCase""" - entity = QuotedReplyEntity( - quoted_reply=QuotedReplyData(message_id="msg-123", sender_name="Alice") - ) + entity = QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id="msg-123", sender_name="Alice")) serialized = entity.model_dump(by_alias=True, exclude_none=True) assert serialized["type"] == "quotedReply" assert "quotedReply" in serialized @@ -102,14 +100,16 @@ def test_serialization_camel_case(self): def test_deserialization_from_camel_case(self): """Test that QuotedReplyEntity deserializes from camelCase""" - entity = QuotedReplyEntity.model_validate({ - "type": "quotedReply", - "quotedReply": { - "messageId": "msg-123", - "senderId": "user-456", - "senderName": "Test User", - }, - }) + entity = QuotedReplyEntity.model_validate( + { + "type": "quotedReply", + "quotedReply": { + "messageId": "msg-123", + "senderId": "user-456", + "senderName": "Test User", + }, + } + ) assert entity.type == "quotedReply" assert entity.quoted_reply.message_id == "msg-123" assert entity.quoted_reply.sender_id == "user-456" @@ -137,4 +137,6 @@ def test_round_trip_serialization(self): assert deserialized.quoted_reply.preview == original.quoted_reply.preview assert deserialized.quoted_reply.time == original.quoted_reply.time assert deserialized.quoted_reply.is_reply_deleted == original.quoted_reply.is_reply_deleted - assert deserialized.quoted_reply.validated_message_reference == original.quoted_reply.validated_message_reference + assert ( + deserialized.quoted_reply.validated_message_reference == original.quoted_reply.validated_message_reference + ) 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 fc84103a..b6553db1 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -203,11 +203,7 @@ async def reply(self, input: str | ActivityParams) -> SentActivity: placeholder = f'' if not activity.entities: activity.entities = [] - activity.entities.append( - QuotedReplyEntity( - quoted_reply=QuotedReplyData(message_id=self.activity.id) - ) - ) + activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=self.activity.id))) text = (activity.text or "").strip() activity.text = f"{placeholder} {text}" if text else placeholder return await self.send(activity) @@ -227,9 +223,7 @@ async def quote_reply(self, message_id: str, input: str | ActivityParams) -> Sen placeholder = f'' if not activity.entities: activity.entities = [] - activity.entities.append( - QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id)) - ) + activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) text = (activity.text or "").strip() activity.text = f"{placeholder} {text}" if text else placeholder return await self.send(activity) From bd0ea4b9baad337178986c538e499a8f9740c02e Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 18 Mar 2026 11:15:30 -0700 Subject: [PATCH 04/16] Quoted replies sample --- .coverage | Bin 0 -> 53248 bytes examples/quoted-replies/README.md | 31 +++++++ examples/quoted-replies/pyproject.toml | 14 +++ examples/quoted-replies/src/main.py | 114 +++++++++++++++++++++++++ uv.lock | 18 ++++ 5 files changed, 177 insertions(+) create mode 100644 .coverage create mode 100644 examples/quoted-replies/README.md create mode 100644 examples/quoted-replies/pyproject.toml create mode 100644 examples/quoted-replies/src/main.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..93d34315fc71ace3ba8b1f2b9c90973b132c7c35 GIT binary patch literal 53248 zcmeI)&1)QG90%}cW;VN-&88D6WgA1ub16yaX1Ae&RSzcBfW?AVTk&GI&(1!ZjNO^p z%*>_sYSm=buOClZAF)#Y zV`g2MeA!wot(E?q_^x!;ygcC)DrS`qV1WPxAOL|wBhb58DwL4i?@_#T&A((XpGxn~6{3N2d*Z?HC$)>T!Zt4Yi> zuK}HYK_ESuOUa^M&%vjCRY6yHVO& z>04o_3uP_2$26K9a-|5nK{k;*ZIaAl^k>OzjHE}XI*WyTd1Yo)b<%M4rF3PySI8C0 z$Bt>eWJIWWNj=AgDxvoDhrq5A!Lt{?BcgTwWZBhjD)`)3zWm0qQ3Y2;s4uD4{Ou_{ zF7jO?)^};rS5IT8$ZAi&$nVPS#ZA$s*`yej4GnN@HzvE-q7ca#7R#B^q?u_@Wip6<#wvU#Zh@k}A#pYH+H2l1YRj z@;MrmAq~-8X=aJ0N7am#rC;rkiAvQK&1AT%Mj1%)`nr4~x#Vp0wuofpxRR?Nabe<) zbzOwq4kR5+&!us}NNjy~sgh*gp(EKGn$%`t&UjkiQB7Q5Qm^VNd0c&z>&vdSHP71Rwwb2tWV=5P$##AOHafK;ZBR7@DpX)b+n^{mQIA=miS|AOHaf zKmY;|fB*y_009U<00J+hKuI@FSlOS2oSxE*nd$Uz0A8&wovFT3q+03LLuNg+9=(ti zM4=%70SG_<0uX=z1Rwwb2tWV=5ZDnY87H*tYXH4y%$Vu70P6RD}zxnkgz5?ov;=LcFlF{zz_XaRFA_a2Liei1KpaZCg}Ddn@+S{Pw!SiN%;DH zGjvAe=VaM9R4!MBAr&xtrwlhW%5Y;pH5{Fx>o;XPtVu7T%GEZyKHd7D9>`96yPoW$ zeAVy&#*rVGb=>-r-mpLb0uX=z1Rwwb2tWV=5P$##4pN|#>)G0Vu8#M4{cr3$!_k-H z>DLTi2)m`j^oI3j`nl0SG_<0uX=z1Rwwb2tWV=hfu&UCe8T&|E;f>^|SR8y1Rwwb z2tWV=5P$##AOHafK;XFwRE?ZwUNP>M+45&6wT|^oh5dcw7s@ivZnAmN`R}*KpWN-2 zAXn6Lrg{9_xA)v1&9fTC4fOj(#;?uXb03_3P-IgSjZH9(v)oPE(3jab(>UX_&U9Lj zk1xmn|8M=ytiPAWz>CXofLHUmR%q7;Tuk z`u$&hBY*`05P$##AOHafKmY;|fB*y_0D;3Rpnm_y_5a~*U(^f&5P$##AOHafKmY;| kfB*y_pak&yKl%X#AOHafKmY;|fB*y_009U<;P4Cl4=P|)K>z>% literal 0 HcmV?d00001 diff --git a/examples/quoted-replies/README.md b/examples/quoted-replies/README.md new file mode 100644 index 00000000..13369ecd --- /dev/null +++ b/examples/quoted-replies/README.md @@ -0,0 +1,31 @@ +# Example: Quoted Replies + +A bot that demonstrates quoted reply features in Microsoft Teams — referencing previous messages when sending responses. + +## Commands + +| Command | Behavior | +|---------|----------| +| `test reply` | `reply()` — auto-quotes the inbound message | +| `test quote` | `quote_reply()` — sends a message, then quotes it by ID | +| `test add` | `add_quoted_reply()` — sends a message, then quotes it with builder + response | +| `test multi` | Sends two messages, then quotes both with interleaved responses | +| `test manual` | `add_quoted_reply()` + `add_text()` — manual control | +| `help` | Shows available commands | +| *(quote a message)* | Bot reads and displays the quoted reply metadata | + +## Run + +```bash +cd examples/quoted-replies +uv run python src/main.py +``` + +## Environment Variables + +Create a `.env` file: + +```env +CLIENT_ID= +CLIENT_SECRET= +``` diff --git a/examples/quoted-replies/pyproject.toml b/examples/quoted-replies/pyproject.toml new file mode 100644 index 00000000..f0923ce0 --- /dev/null +++ b/examples/quoted-replies/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "quoted-replies" +version = "0.1.0" +description = "Quoted replies example - demonstrates quoting previous messages in conversations" +readme = "README.md" +requires-python = ">=3.12,<3.15" +dependencies = [ + "dotenv>=0.9.9", + "microsoft-teams-apps", + "microsoft-teams-api", +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } diff --git a/examples/quoted-replies/src/main.py b/examples/quoted-replies/src/main.py new file mode 100644 index 00000000..334dfab1 --- /dev/null +++ b/examples/quoted-replies/src/main.py @@ -0,0 +1,114 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio + +from microsoft_teams.api import MessageActivity, MessageActivityInput +from microsoft_teams.api.activities.typing import TypingActivityInput +from microsoft_teams.apps import ActivityContext, App + +""" +Example: Quoted Replies + +A bot that demonstrates quoted reply features in Microsoft Teams. +Tests reply(), quote_reply(), add_quoted_reply(), and get_quoted_messages(). +""" + +app = App() + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + """Handle message activities.""" + await ctx.reply(TypingActivityInput()) + + text = (ctx.activity.text or "").lower() + + # ============================================ + # Read inbound quoted replies + # ============================================ + quotes = ctx.activity.get_quoted_messages() + if quotes: + quote = quotes[0].quoted_reply + info_parts = [f"Quoted message ID: {quote.message_id}"] + if quote.sender_name: + info_parts.append(f"From: {quote.sender_name}") + if quote.preview: + info_parts.append(f'Preview: "{quote.preview}"') + if quote.is_reply_deleted: + info_parts.append("(deleted)") + if quote.validated_message_reference: + info_parts.append("(validated)") + + await ctx.send("You sent a message with a quoted reply:\n\n" + "\n".join(info_parts)) + + # ============================================ + # reply() — auto-quotes the inbound message + # ============================================ + if "test reply" in text: + await ctx.reply("This reply auto-quotes your message using reply()") + return + + # ============================================ + # quote_reply() — quote a previously sent message by ID + # ============================================ + if "test quote" in text: + sent = await ctx.send("This message will be quoted next...") + await ctx.quote_reply(sent.id, "This quotes the message above using quote_reply()") + return + + # ============================================ + # add_quoted_reply() — builder with response + # ============================================ + if "test add" in text: + sent = await ctx.send("This message will be quoted next...") + msg = MessageActivityInput().add_quoted_reply(sent.id, "This uses add_quoted_reply() with a response") + await ctx.send(msg) + return + + # ============================================ + # Multi-quote interleaved + # ============================================ + if "test multi" in text: + sent_a = await ctx.send("Message A — will be quoted") + sent_b = await ctx.send("Message B — will be quoted") + msg = ( + MessageActivityInput() + .add_quoted_reply(sent_a.id, "Response to A") + .add_quoted_reply(sent_b.id, "Response to B") + ) + await ctx.send(msg) + return + + # ============================================ + # add_quoted_reply() + add_text() — manual control + # ============================================ + if "test manual" in text: + sent = await ctx.send("This message will be quoted next...") + msg = MessageActivityInput().add_quoted_reply(sent.id).add_text(" Custom text after the quote placeholder") + await ctx.send(msg) + return + + # ============================================ + # Help / Default + # ============================================ + if "help" in text: + await ctx.reply( + "**Quoted Replies Test Bot**\n\n" + "**Commands:**\n" + "- `test reply` - reply() auto-quotes your message\n" + "- `test quote` - quote_reply() quotes a previously sent message\n" + "- `test add` - add_quoted_reply() builder with response\n" + "- `test multi` - Multi-quote interleaved (quotes two separate messages)\n" + "- `test manual` - add_quoted_reply() + add_text() manual control\n\n" + "Quote any message to me to see the parsed metadata!" + ) + return + + await ctx.reply('Say "help" for available commands.') + + +if __name__ == "__main__": + asyncio.run(app.start()) diff --git a/uv.lock b/uv.lock index 31f256ca..7bba118f 100644 --- a/uv.lock +++ b/uv.lock @@ -33,6 +33,7 @@ members = [ "microsoft-teams-openai", "oauth", "proactive-messaging", + "quoted-replies", "reactions", "stream", "tab", @@ -2906,6 +2907,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "quoted-replies" +version = "0.1.0" +source = { virtual = "examples/quoted-replies" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, +] + [[package]] name = "reactions" version = "0.1.0" From 89a5c34292865fe60516760a0a9091d9dd62101c Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 18 Mar 2026 15:08:26 -0700 Subject: [PATCH 05/16] Mark as experimental and remove replyToId change --- .../api/activities/message/message.py | 8 ++++++++ .../api/models/entity/quoted_reply_entity.py | 18 ++++++++++++++++-- .../apps/routing/activity_context.py | 5 ++++- packages/apps/tests/test_quoted_reply.py | 2 -- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/activities/message/message.py b/packages/api/src/microsoft_teams/api/activities/message/message.py index 55106d52..dad10a3d 100644 --- a/packages/api/src/microsoft_teams/api/activities/message/message.py +++ b/packages/api/src/microsoft_teams/api/activities/message/message.py @@ -90,6 +90,10 @@ def get_quoted_messages(self) -> list[QuotedReplyEntity]: Returns: List of quoted reply entities, empty if none + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: ExperimentalTeamsQuotedReplies """ return [e for e in (self.entities or []) if isinstance(e, QuotedReplyEntity)] @@ -419,6 +423,10 @@ def add_quoted_reply(self, message_id: str, response: str | None = None) -> Self Returns: Self for method chaining + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: ExperimentalTeamsQuotedReplies """ if not self.entities: self.entities = [] diff --git a/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py index 4f05d310..cdb3af44 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py +++ b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py @@ -5,11 +5,19 @@ from typing import Literal, Optional +from microsoft_teams.common.experimental import experimental + from ..custom_base_model import CustomBaseModel +@experimental("ExperimentalTeamsQuotedReplies") class QuotedReplyData(CustomBaseModel): - """Data for a quoted reply entity""" + """Data for a quoted reply entity + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: ExperimentalTeamsQuotedReplies + """ message_id: str "ID of the message being quoted" @@ -33,8 +41,14 @@ class QuotedReplyData(CustomBaseModel): "Whether the message reference has been validated" +@experimental("ExperimentalTeamsQuotedReplies") class QuotedReplyEntity(CustomBaseModel): - """Entity containing quoted reply information""" + """Entity containing quoted reply information + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: ExperimentalTeamsQuotedReplies + """ type: Literal["quotedReply"] = "quotedReply" "Type identifier for quoted reply" 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 b6553db1..8e385c01 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -198,7 +198,6 @@ async def reply(self, input: str | ActivityParams) -> SentActivity: The sent activity """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input - activity.reply_to_id = self.activity.id if isinstance(activity, MessageActivityInput) and self.activity.id: placeholder = f'' if not activity.entities: @@ -218,6 +217,10 @@ async def quote_reply(self, message_id: str, input: str | ActivityParams) -> Sen Returns: The sent activity + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: ExperimentalTeamsQuotedReplies """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input placeholder = f'' diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py index fae74f15..de58444e 100644 --- a/packages/apps/tests/test_quoted_reply.py +++ b/packages/apps/tests/test_quoted_reply.py @@ -60,7 +60,6 @@ async def test_reply_stamps_quoted_reply_entity(self) -> None: await ctx.reply("Thanks!") sent_activity = ctx._activity_sender.send.call_args[0][0] - assert sent_activity.reply_to_id == "msg-abc" assert sent_activity.entities is not None assert len(sent_activity.entities) == 1 entity = sent_activity.entities[0] @@ -131,7 +130,6 @@ async def test_reply_with_activity_params(self) -> None: sent_activity = ctx._activity_sender.send.call_args[0][0] assert sent_activity.text == ' Hello world' - assert sent_activity.reply_to_id == "msg-abc" class TestActivityContextQuoteReply: From 8e5eb724680a460147ffa9a946d32d31798a1ffa Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Mon, 23 Mar 2026 16:03:55 -0700 Subject: [PATCH 06/16] Update examples --- examples/quoted-replies/src/main.py | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/examples/quoted-replies/src/main.py b/examples/quoted-replies/src/main.py index 334dfab1..6cc731dd 100644 --- a/examples/quoted-replies/src/main.py +++ b/examples/quoted-replies/src/main.py @@ -48,36 +48,38 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): # reply() — auto-quotes the inbound message # ============================================ if "test reply" in text: - await ctx.reply("This reply auto-quotes your message using reply()") + await ctx.reply("Thanks for your message! This reply auto-quotes it using reply().") return # ============================================ # quote_reply() — quote a previously sent message by ID # ============================================ if "test quote" in text: - sent = await ctx.send("This message will be quoted next...") - await ctx.quote_reply(sent.id, "This quotes the message above using quote_reply()") + sent = await ctx.send("The meeting has been moved to 3 PM tomorrow.") + await ctx.quote_reply(sent.id, "Just to confirm — does the new time work for everyone?") return # ============================================ # add_quoted_reply() — builder with response # ============================================ if "test add" in text: - sent = await ctx.send("This message will be quoted next...") - msg = MessageActivityInput().add_quoted_reply(sent.id, "This uses add_quoted_reply() with a response") + sent = await ctx.send("Please review the latest PR before end of day.") + msg = MessageActivityInput().add_quoted_reply(sent.id, "Done! Left my comments on the PR.") await ctx.send(msg) return # ============================================ - # Multi-quote interleaved + # Multi-quote with mixed responses # ============================================ if "test multi" in text: - sent_a = await ctx.send("Message A — will be quoted") - sent_b = await ctx.send("Message B — will be quoted") + sent_a = await ctx.send("We need to update the API docs before launch.") + sent_b = await ctx.send("The design mockups are ready for review.") + sent_c = await ctx.send("CI pipeline is green on main.") msg = ( MessageActivityInput() - .add_quoted_reply(sent_a.id, "Response to A") - .add_quoted_reply(sent_b.id, "Response to B") + .add_quoted_reply(sent_a.id, "I can take the docs — will have a draft by Thursday.") + .add_quoted_reply(sent_b.id, "Looks great, approved!") + .add_quoted_reply(sent_c.id) ) await ctx.send(msg) return @@ -86,8 +88,8 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): # add_quoted_reply() + add_text() — manual control # ============================================ if "test manual" in text: - sent = await ctx.send("This message will be quoted next...") - msg = MessageActivityInput().add_quoted_reply(sent.id).add_text(" Custom text after the quote placeholder") + sent = await ctx.send("Deployment to staging is complete.") + msg = MessageActivityInput().add_quoted_reply(sent.id).add_text(" Verified — all smoke tests passing.") await ctx.send(msg) return @@ -101,7 +103,7 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): "- `test reply` - reply() auto-quotes your message\n" "- `test quote` - quote_reply() quotes a previously sent message\n" "- `test add` - add_quoted_reply() builder with response\n" - "- `test multi` - Multi-quote interleaved (quotes two separate messages)\n" + "- `test multi` - Multi-quote with mixed responses (one bare quote with no response)\n" "- `test manual` - add_quoted_reply() + add_text() manual control\n\n" "Quote any message to me to see the parsed metadata!" ) From e79696937539d355b801c558c81d080a2a18c162 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Mon, 23 Mar 2026 16:48:46 -0700 Subject: [PATCH 07/16] Fix pyright errors in quote_reply() type narrowing --- .../apps/routing/activity_context.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 8e385c01..c9679783 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -7,7 +7,7 @@ import json import logging from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Optional, TypeVar, cast +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Optional, TypeVar from microsoft_teams.api import ( ActivityBase, @@ -223,12 +223,13 @@ async def quote_reply(self, message_id: str, input: str | ActivityParams) -> Sen Diagnostic: ExperimentalTeamsQuotedReplies """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input - placeholder = f'' - if not activity.entities: - activity.entities = [] - activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) - text = (activity.text or "").strip() - activity.text = f"{placeholder} {text}" if text else placeholder + if isinstance(activity, MessageActivityInput): + placeholder = f'' + if not activity.entities: + activity.entities = [] + activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) + text = (activity.text or "").strip() + activity.text = f"{placeholder} {text}" if text else placeholder return await self.send(activity) async def next(self) -> None: From f238d35003af18322bcd8342d227603bbdecf4b4 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Mon, 23 Mar 2026 17:04:51 -0700 Subject: [PATCH 08/16] Fix tests --- packages/apps/tests/test_activity_context.py | 74 ++++---------------- packages/apps/tests/test_quoted_reply.py | 2 - 2 files changed, 15 insertions(+), 61 deletions(-) diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index ab1346b6..4efc5313 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -197,14 +197,13 @@ class TestActivityContextReply: @pytest.mark.asyncio async def test_reply_with_string(self) -> None: - """reply() with a plain string sends a message with reply_to_id set.""" + """reply() with a plain string stamps a quotedReply entity and placeholder.""" + from microsoft_teams.api.models.entity import QuotedReplyEntity + mock_activity = MagicMock() mock_activity.type = "message" mock_activity.id = "original-id" mock_activity.text = "Original message" - mock_activity.from_ = MagicMock() - mock_activity.from_.id = "user-1" - mock_activity.from_.name = "User One" ctx, mock_sender = _create_activity_context(activity=mock_activity) @@ -213,14 +212,20 @@ async def test_reply_with_string(self) -> None: mock_sender.send.assert_called_once() sent_activity = mock_sender.send.call_args[0][0] assert isinstance(sent_activity, MessageActivityInput) - assert sent_activity.reply_to_id == "original-id" + assert sent_activity.entities is not None + assert len(sent_activity.entities) == 1 + assert isinstance(sent_activity.entities[0], QuotedReplyEntity) + assert sent_activity.entities[0].quoted_reply.message_id == "original-id" + assert '' in (sent_activity.text or "") assert "My reply" in (sent_activity.text or "") @pytest.mark.asyncio async def test_reply_with_activity_params(self) -> None: - """reply() with an ActivityParams instance sets reply_to_id and delegates to send.""" + """reply() with a MessageActivityInput stamps a quotedReply entity.""" + from microsoft_teams.api.models.entity import QuotedReplyEntity + mock_activity = MagicMock() - mock_activity.type = "event" + mock_activity.type = "message" mock_activity.id = "evt-id-999" ctx, mock_sender = _create_activity_context(activity=mock_activity) @@ -230,58 +235,9 @@ async def test_reply_with_activity_params(self) -> None: mock_sender.send.assert_called_once() sent_activity = mock_sender.send.call_args[0][0] - assert sent_activity.reply_to_id == "evt-id-999" - - -class TestActivityContextBuildBlockQuote: - """Tests for ActivityContext._build_block_quote_for_activity().""" - - def _make_message_activity(self, text: str, activity_id: str = "act-1") -> MagicMock: - mock_activity = MagicMock() - mock_activity.type = "message" - mock_activity.id = activity_id - mock_activity.text = text - mock_activity.from_ = MagicMock() - mock_activity.from_.id = "user-xyz" - mock_activity.from_.name = "Test User" - return mock_activity - - def test_message_activity_returns_html_blockquote(self) -> None: - """Activity type 'message' with text produces a blockquote HTML string.""" - activity = self._make_message_activity("Hello blockquote") - ctx, _ = _create_activity_context(activity=activity) - - result = ctx._build_block_quote_for_activity() - - assert result is not None - assert " None: - """Text longer than 120 characters is truncated with an ellipsis.""" - long_text = "A" * 130 - activity = self._make_message_activity(long_text) - ctx, _ = _create_activity_context(activity=activity) - - result = ctx._build_block_quote_for_activity() - - assert result is not None - # Truncated text should be 120 chars + "..." - assert "A" * 120 + "..." in result - # The full text should not be present - assert long_text not in result - - def test_non_message_activity_returns_none(self) -> None: - """Activity type other than 'message' returns None.""" - mock_activity = MagicMock() - mock_activity.type = "event" - ctx, _ = _create_activity_context(activity=mock_activity) - - result = ctx._build_block_quote_for_activity() - - assert result is None + assert sent_activity.entities is not None + assert isinstance(sent_activity.entities[0], QuotedReplyEntity) + assert sent_activity.entities[0].quoted_reply.message_id == "evt-id-999" class TestActivityContextUserGraph: diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py index de58444e..c0da9a16 100644 --- a/packages/apps/tests/test_quoted_reply.py +++ b/packages/apps/tests/test_quoted_reply.py @@ -42,7 +42,6 @@ def _create_activity_context( return ActivityContext( activity=mock_activity, app_id="test-app-id", - logger=MagicMock(), storage=MagicMock(), api=MagicMock(), user_token=None, @@ -155,7 +154,6 @@ def _create_activity_context(self) -> ActivityContext[Any]: return ActivityContext( activity=mock_activity, app_id="test-app-id", - logger=MagicMock(), storage=MagicMock(), api=MagicMock(), user_token=None, From a7a03665e5dbaf47ea78c7d4819043178a071a03 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Tue, 24 Mar 2026 14:49:50 -0700 Subject: [PATCH 09/16] PR Feedback --- .coverage | Bin 53248 -> 0 bytes examples/quoted-replies/src/main.py | 12 +++++------- .../apps/routing/activity_context.py | 8 ++++---- packages/apps/tests/test_quoted_reply.py | 4 ++-- 4 files changed, 11 insertions(+), 13 deletions(-) delete mode 100644 .coverage diff --git a/.coverage b/.coverage deleted file mode 100644 index 93d34315fc71ace3ba8b1f2b9c90973b132c7c35..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53248 zcmeI)&1)QG90%}cW;VN-&88D6WgA1ub16yaX1Ae&RSzcBfW?AVTk&GI&(1!ZjNO^p z%*>_sYSm=buOClZAF)#Y zV`g2MeA!wot(E?q_^x!;ygcC)DrS`qV1WPxAOL|wBhb58DwL4i?@_#T&A((XpGxn~6{3N2d*Z?HC$)>T!Zt4Yi> zuK}HYK_ESuOUa^M&%vjCRY6yHVO& z>04o_3uP_2$26K9a-|5nK{k;*ZIaAl^k>OzjHE}XI*WyTd1Yo)b<%M4rF3PySI8C0 z$Bt>eWJIWWNj=AgDxvoDhrq5A!Lt{?BcgTwWZBhjD)`)3zWm0qQ3Y2;s4uD4{Ou_{ zF7jO?)^};rS5IT8$ZAi&$nVPS#ZA$s*`yej4GnN@HzvE-q7ca#7R#B^q?u_@Wip6<#wvU#Zh@k}A#pYH+H2l1YRj z@;MrmAq~-8X=aJ0N7am#rC;rkiAvQK&1AT%Mj1%)`nr4~x#Vp0wuofpxRR?Nabe<) zbzOwq4kR5+&!us}NNjy~sgh*gp(EKGn$%`t&UjkiQB7Q5Qm^VNd0c&z>&vdSHP71Rwwb2tWV=5P$##AOHafK;ZBR7@DpX)b+n^{mQIA=miS|AOHaf zKmY;|fB*y_009U<00J+hKuI@FSlOS2oSxE*nd$Uz0A8&wovFT3q+03LLuNg+9=(ti zM4=%70SG_<0uX=z1Rwwb2tWV=5ZDnY87H*tYXH4y%$Vu70P6RD}zxnkgz5?ov;=LcFlF{zz_XaRFA_a2Liei1KpaZCg}Ddn@+S{Pw!SiN%;DH zGjvAe=VaM9R4!MBAr&xtrwlhW%5Y;pH5{Fx>o;XPtVu7T%GEZyKHd7D9>`96yPoW$ zeAVy&#*rVGb=>-r-mpLb0uX=z1Rwwb2tWV=5P$##4pN|#>)G0Vu8#M4{cr3$!_k-H z>DLTi2)m`j^oI3j`nl0SG_<0uX=z1Rwwb2tWV=hfu&UCe8T&|E;f>^|SR8y1Rwwb z2tWV=5P$##AOHafK;XFwRE?ZwUNP>M+45&6wT|^oh5dcw7s@ivZnAmN`R}*KpWN-2 zAXn6Lrg{9_xA)v1&9fTC4fOj(#;?uXb03_3P-IgSjZH9(v)oPE(3jab(>UX_&U9Lj zk1xmn|8M=ytiPAWz>CXofLHUmR%q7;Tuk z`u$&hBY*`05P$##AOHafKmY;|fB*y_0D;3Rpnm_y_5a~*U(^f&5P$##AOHafKmY;| kfB*y_pak&yKl%X#AOHafKmY;|fB*y_009U<;P4Cl4=P|)K>z>% diff --git a/examples/quoted-replies/src/main.py b/examples/quoted-replies/src/main.py index 6cc731dd..7a80e8f5 100644 --- a/examples/quoted-replies/src/main.py +++ b/examples/quoted-replies/src/main.py @@ -1,6 +1,11 @@ """ Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT License. + +Example: Quoted Replies + +A bot that demonstrates quoted reply features in Microsoft Teams. +Tests reply(), quote_reply(), add_quoted_reply(), and get_quoted_messages(). """ import asyncio @@ -9,13 +14,6 @@ from microsoft_teams.api.activities.typing import TypingActivityInput from microsoft_teams.apps import ActivityContext, App -""" -Example: Quoted Replies - -A bot that demonstrates quoted reply features in Microsoft Teams. -Tests reply(), quote_reply(), add_quoted_reply(), and get_quoted_messages(). -""" - app = App() 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 c9679783..dcc20aea 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -203,8 +203,8 @@ async def reply(self, input: str | ActivityParams) -> SentActivity: if not activity.entities: activity.entities = [] activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=self.activity.id))) - text = (activity.text or "").strip() - activity.text = f"{placeholder} {text}" if text else placeholder + has_text = bool((activity.text or "").strip()) + activity.text = f"{placeholder} {activity.text}" if has_text else placeholder return await self.send(activity) async def quote_reply(self, message_id: str, input: str | ActivityParams) -> SentActivity: @@ -228,8 +228,8 @@ async def quote_reply(self, message_id: str, input: str | ActivityParams) -> Sen if not activity.entities: activity.entities = [] activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) - text = (activity.text or "").strip() - activity.text = f"{placeholder} {text}" if text else placeholder + has_text = bool((activity.text or "").strip()) + activity.text = f"{placeholder} {activity.text}" if has_text else placeholder return await self.send(activity) async def next(self) -> None: diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py index c0da9a16..f92c5c08 100644 --- a/packages/apps/tests/test_quoted_reply.py +++ b/packages/apps/tests/test_quoted_reply.py @@ -3,7 +3,7 @@ Licensed under the MIT License. """ -from typing import Any +from typing import Any, Optional from unittest.mock import AsyncMock, MagicMock import pytest @@ -17,7 +17,7 @@ class TestActivityContextReply: def _create_activity_context( self, - activity_id: str = "incoming-msg-123", + activity_id: Optional[str] = "incoming-msg-123", activity_type: str = "message", activity_text: str = "Hello from user", ) -> ActivityContext[Any]: From 9182616db7ea2e431e8696eef0645d9399ea8586 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 12:42:45 -0700 Subject: [PATCH 10/16] Update sample README.md --- examples/quoted-replies/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/quoted-replies/README.md b/examples/quoted-replies/README.md index 13369ecd..461db29a 100644 --- a/examples/quoted-replies/README.md +++ b/examples/quoted-replies/README.md @@ -18,6 +18,10 @@ A bot that demonstrates quoted reply features in Microsoft Teams — referencing ```bash cd examples/quoted-replies +pip install -e . +python src/main.py + +# Or with uv: uv run python src/main.py ``` From 27f5d89d03974198cfd6168e00681e4d09f53df1 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 15:16:33 -0700 Subject: [PATCH 11/16] Add docstring for time field format (IC3 epoch) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../microsoft_teams/api/models/entity/quoted_reply_entity.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py index cdb3af44..19e34d31 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py +++ b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py @@ -32,7 +32,7 @@ class QuotedReplyData(CustomBaseModel): "Preview text of the quoted message" time: Optional[str] = None - "Timestamp of the quoted message" + "Timestamp of the quoted message (IC3 epoch value, e.g. '1772050244572'). Inbound only." is_reply_deleted: Optional[bool] = None "Whether the quoted reply has been deleted" From cde6dfeff24b3df3afeda2e9be29859d31cba4fd Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 17:26:38 -0700 Subject: [PATCH 12/16] Fix test string --- packages/api/tests/unit/test_quoted_reply_entity.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/api/tests/unit/test_quoted_reply_entity.py b/packages/api/tests/unit/test_quoted_reply_entity.py index 2ac3403a..5480c766 100644 --- a/packages/api/tests/unit/test_quoted_reply_entity.py +++ b/packages/api/tests/unit/test_quoted_reply_entity.py @@ -28,7 +28,7 @@ def test_full_creation(self): sender_id="user-456", sender_name="Test User", preview="Hello world", - time="2024-01-01T12:00:00Z", + time="1772050244572", is_reply_deleted=False, validated_message_reference=True, ) @@ -36,7 +36,7 @@ def test_full_creation(self): assert data.sender_id == "user-456" assert data.sender_name == "Test User" assert data.preview == "Hello world" - assert data.time == "2024-01-01T12:00:00Z" + assert data.time == "1772050244572" assert data.is_reply_deleted is False assert data.validated_message_reference is True @@ -67,7 +67,7 @@ def test_deserialization_from_camel_case(self): "senderId": "user-456", "senderName": "Test User", "preview": "Hello world", - "time": "2024-01-01T12:00:00Z", + "time": "1772050244572", "isReplyDeleted": False, "validatedMessageReference": True, } @@ -123,7 +123,7 @@ def test_round_trip_serialization(self): sender_id="user-456", sender_name="Test User", preview="Hello world", - time="2024-01-01T12:00:00Z", + time="1772050244572", is_reply_deleted=False, validated_message_reference=True, ) From 8cb1b404a925469eca24b76a1302b7b14e4639ed Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 17:26:51 -0700 Subject: [PATCH 13/16] Have reply defer quote_reply --- .../src/microsoft_teams/apps/routing/activity_context.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) 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 dcc20aea..f540136e 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -197,14 +197,9 @@ async def reply(self, input: str | ActivityParams) -> SentActivity: Returns: The sent activity """ + if self.activity.id: + return await self.quote_reply(self.activity.id, input) activity = MessageActivityInput(text=input) if isinstance(input, str) else input - if isinstance(activity, MessageActivityInput) and self.activity.id: - placeholder = f'' - if not activity.entities: - activity.entities = [] - activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=self.activity.id))) - has_text = bool((activity.text or "").strip()) - activity.text = f"{placeholder} {activity.text}" if has_text else placeholder return await self.send(activity) async def quote_reply(self, message_id: str, input: str | ActivityParams) -> SentActivity: From b284672f555a652fcb62ee2a0bcb265acfe6bc43 Mon Sep 17 00:00:00 2001 From: Corina Gum <> Date: Wed, 25 Mar 2026 17:53:11 -0700 Subject: [PATCH 14/16] Move stamping to model layer --- .../api/activities/message/message.py | 23 +++++++++++++++++++ .../apps/routing/activity_context.py | 8 +------ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/api/src/microsoft_teams/api/activities/message/message.py b/packages/api/src/microsoft_teams/api/activities/message/message.py index dad10a3d..93c71d36 100644 --- a/packages/api/src/microsoft_teams/api/activities/message/message.py +++ b/packages/api/src/microsoft_teams/api/activities/message/message.py @@ -412,6 +412,29 @@ def add_stream_final(self) -> Self: return self.add_entity(stream_entity) + def prepend_quoted_reply(self, message_id: str) -> Self: + """ + Prepend a quotedReply entity and placeholder before existing text. + Used by reply()/quote_reply() for quote-above-response. + + Args: + message_id: The IC3 message ID of the message to quote + + Returns: + Self for method chaining + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: ExperimentalTeamsQuotedReplies + """ + if not self.entities: + self.entities = [] + self.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) + placeholder = f'' + has_text = bool((self.text or "").strip()) + self.text = f"{placeholder} {self.text}" if has_text else placeholder + return self + def add_quoted_reply(self, message_id: str, response: str | None = None) -> Self: """ Add a quotedReply entity for the given message ID and append a placeholder to text. 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 f540136e..3a44cdff 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -31,7 +31,6 @@ OAuthCardAttachment, card_attachment, ) -from microsoft_teams.api.models.entity import QuotedReplyData, QuotedReplyEntity from microsoft_teams.api.models.oauth import OAuthCard from microsoft_teams.cards import AdaptiveCard from microsoft_teams.common import Storage @@ -219,12 +218,7 @@ async def quote_reply(self, message_id: str, input: str | ActivityParams) -> Sen """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input if isinstance(activity, MessageActivityInput): - placeholder = f'' - if not activity.entities: - activity.entities = [] - activity.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) - has_text = bool((activity.text or "").strip()) - activity.text = f"{placeholder} {activity.text}" if has_text else placeholder + activity.prepend_quoted_reply(message_id) return await self.send(activity) async def next(self) -> None: From 2c4df113dbdc42110edaf5d217b02c460a249778 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Thu, 26 Mar 2026 16:09:39 -0700 Subject: [PATCH 15/16] Update to quote and verbiage improvements --- examples/quoted-replies/README.md | 6 ++--- examples/quoted-replies/src/main.py | 26 +++++++++---------- .../api/activities/message/message.py | 19 +++++++------- .../unit/test_quoted_replies_property.py | 24 ++++++++--------- .../apps/routing/activity_context.py | 11 ++++---- packages/apps/tests/test_quoted_reply.py | 26 +++++++++---------- 6 files changed, 57 insertions(+), 55 deletions(-) diff --git a/examples/quoted-replies/README.md b/examples/quoted-replies/README.md index 461db29a..8928579d 100644 --- a/examples/quoted-replies/README.md +++ b/examples/quoted-replies/README.md @@ -7,10 +7,10 @@ A bot that demonstrates quoted reply features in Microsoft Teams — referencing | Command | Behavior | |---------|----------| | `test reply` | `reply()` — auto-quotes the inbound message | -| `test quote` | `quote_reply()` — sends a message, then quotes it by ID | -| `test add` | `add_quoted_reply()` — sends a message, then quotes it with builder + response | +| `test quote` | `quote()` — sends a message, then quotes it by ID | +| `test add` | `add_quote()` — sends a message, then quotes it with builder + response | | `test multi` | Sends two messages, then quotes both with interleaved responses | -| `test manual` | `add_quoted_reply()` + `add_text()` — manual control | +| `test manual` | `add_quote()` + `add_text()` — manual control | | `help` | Shows available commands | | *(quote a message)* | Bot reads and displays the quoted reply metadata | diff --git a/examples/quoted-replies/src/main.py b/examples/quoted-replies/src/main.py index 7a80e8f5..d6599def 100644 --- a/examples/quoted-replies/src/main.py +++ b/examples/quoted-replies/src/main.py @@ -5,7 +5,7 @@ Example: Quoted Replies A bot that demonstrates quoted reply features in Microsoft Teams. -Tests reply(), quote_reply(), add_quoted_reply(), and get_quoted_messages(). +Tests reply(), quote(), add_quote(), and get_quoted_messages(). """ import asyncio @@ -50,19 +50,19 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): return # ============================================ - # quote_reply() — quote a previously sent message by ID + # quote() — quote a previously sent message by ID # ============================================ if "test quote" in text: sent = await ctx.send("The meeting has been moved to 3 PM tomorrow.") - await ctx.quote_reply(sent.id, "Just to confirm — does the new time work for everyone?") + await ctx.quote(sent.id, "Just to confirm — does the new time work for everyone?") return # ============================================ - # add_quoted_reply() — builder with response + # add_quote() — builder with response # ============================================ if "test add" in text: sent = await ctx.send("Please review the latest PR before end of day.") - msg = MessageActivityInput().add_quoted_reply(sent.id, "Done! Left my comments on the PR.") + msg = MessageActivityInput().add_quote(sent.id, "Done! Left my comments on the PR.") await ctx.send(msg) return @@ -75,19 +75,19 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): sent_c = await ctx.send("CI pipeline is green on main.") msg = ( MessageActivityInput() - .add_quoted_reply(sent_a.id, "I can take the docs — will have a draft by Thursday.") - .add_quoted_reply(sent_b.id, "Looks great, approved!") - .add_quoted_reply(sent_c.id) + .add_quote(sent_a.id, "I can take the docs — will have a draft by Thursday.") + .add_quote(sent_b.id, "Looks great, approved!") + .add_quote(sent_c.id) ) await ctx.send(msg) return # ============================================ - # add_quoted_reply() + add_text() — manual control + # add_quote() + add_text() — manual control # ============================================ if "test manual" in text: sent = await ctx.send("Deployment to staging is complete.") - msg = MessageActivityInput().add_quoted_reply(sent.id).add_text(" Verified — all smoke tests passing.") + msg = MessageActivityInput().add_quote(sent.id).add_text(" Verified — all smoke tests passing.") await ctx.send(msg) return @@ -99,10 +99,10 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): "**Quoted Replies Test Bot**\n\n" "**Commands:**\n" "- `test reply` - reply() auto-quotes your message\n" - "- `test quote` - quote_reply() quotes a previously sent message\n" - "- `test add` - add_quoted_reply() builder with response\n" + "- `test quote` - quote() quotes a previously sent message\n" + "- `test add` - add_quote() builder with response\n" "- `test multi` - Multi-quote with mixed responses (one bare quote with no response)\n" - "- `test manual` - add_quoted_reply() + add_text() manual control\n\n" + "- `test manual` - add_quote() + add_text() manual control\n\n" "Quote any message to me to see the parsed metadata!" ) return diff --git a/packages/api/src/microsoft_teams/api/activities/message/message.py b/packages/api/src/microsoft_teams/api/activities/message/message.py index 93c71d36..f6181122 100644 --- a/packages/api/src/microsoft_teams/api/activities/message/message.py +++ b/packages/api/src/microsoft_teams/api/activities/message/message.py @@ -412,10 +412,10 @@ def add_stream_final(self) -> Self: return self.add_entity(stream_entity) - def prepend_quoted_reply(self, message_id: str) -> Self: + def prepend_quote(self, message_id: str) -> Self: """ Prepend a quotedReply entity and placeholder before existing text. - Used by reply()/quote_reply() for quote-above-response. + Used by reply()/quote() for quote-above-response. Args: message_id: The IC3 message ID of the message to quote @@ -435,14 +435,15 @@ def prepend_quoted_reply(self, message_id: str) -> Self: self.text = f"{placeholder} {self.text}" if has_text else placeholder return self - def add_quoted_reply(self, message_id: str, response: str | None = None) -> Self: + def add_quote(self, message_id: str, text: str | None = None) -> Self: """ - Add a quotedReply entity for the given message ID and append a placeholder to text. - If response is provided, it is appended after the placeholder. + Add a quoted message reference and append a placeholder to text. + Teams renders the quoted message as a preview bubble above the response text. + If text is provided, it is appended to the quoted message placeholder. Args: - message_id: The IC3 message ID of the message to quote - response: Optional response text to append after the placeholder + message_id: The ID of the message to quote + text: Optional text, appended to the quoted message placeholder Returns: Self for method chaining @@ -455,8 +456,8 @@ def add_quoted_reply(self, message_id: str, response: str | None = None) -> Self self.entities = [] self.entities.append(QuotedReplyEntity(quoted_reply=QuotedReplyData(message_id=message_id))) self.add_text(f'') - if response: - self.add_text(f" {response}") + if text: + self.add_text(f" {text}") return self def with_recipient(self, value: Account, is_targeted: Optional[bool] = None) -> Self: diff --git a/packages/api/tests/unit/test_quoted_replies_property.py b/packages/api/tests/unit/test_quoted_replies_property.py index 5ee2e781..9c34634c 100644 --- a/packages/api/tests/unit/test_quoted_replies_property.py +++ b/packages/api/tests/unit/test_quoted_replies_property.py @@ -62,42 +62,42 @@ def test_get_quoted_messages_returns_multiple(self): class TestMessageActivityInputAddQuotedReply: - """Tests for the add_quoted_reply builder method on MessageActivityInput""" + """Tests for the add_quote builder method on MessageActivityInput""" - def test_add_quoted_reply_adds_entity_and_placeholder(self): + def test_add_quote_adds_entity_and_placeholder(self): from microsoft_teams.api.activities.message import MessageActivityInput - msg = MessageActivityInput().add_quoted_reply("msg-1") + msg = MessageActivityInput().add_quote("msg-1") assert len(msg.entities) == 1 assert msg.entities[0].type == "quotedReply" assert msg.entities[0].quoted_reply.message_id == "msg-1" assert msg.text == '' - def test_add_quoted_reply_with_response(self): + def test_add_quote_with_response(self): from microsoft_teams.api.activities.message import MessageActivityInput - msg = MessageActivityInput().add_quoted_reply("msg-1", "my response") + msg = MessageActivityInput().add_quote("msg-1", "my response") assert msg.text == ' my response' - def test_add_quoted_reply_multi_quote_interleaved(self): + def test_add_quote_multi_quote_interleaved(self): from microsoft_teams.api.activities.message import MessageActivityInput msg = ( MessageActivityInput() - .add_quoted_reply("msg-1", "response to first") - .add_quoted_reply("msg-2", "response to second") + .add_quote("msg-1", "response to first") + .add_quote("msg-2", "response to second") ) assert msg.text == ' response to first response to second' assert len(msg.entities) == 2 - def test_add_quoted_reply_grouped(self): + def test_add_quote_grouped(self): from microsoft_teams.api.activities.message import MessageActivityInput - msg = MessageActivityInput().add_quoted_reply("msg-1").add_quoted_reply("msg-2", "response to both") + msg = MessageActivityInput().add_quote("msg-1").add_quote("msg-2", "response to both") assert msg.text == ' response to both' - def test_add_quoted_reply_chainable_with_add_text(self): + def test_add_quote_chainable_with_add_text(self): from microsoft_teams.api.activities.message import MessageActivityInput - msg = MessageActivityInput().add_quoted_reply("msg-1").add_text(" manual text") + msg = MessageActivityInput().add_quote("msg-1").add_text(" manual text") assert msg.text == ' manual text' 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 3a44cdff..6397f633 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -197,17 +197,18 @@ async def reply(self, input: str | ActivityParams) -> SentActivity: The sent activity """ if self.activity.id: - return await self.quote_reply(self.activity.id, input) + return await self.quote(self.activity.id, input) activity = MessageActivityInput(text=input) if isinstance(input, str) else input return await self.send(activity) - async def quote_reply(self, message_id: str, input: str | ActivityParams) -> SentActivity: + async def quote(self, message_id: str, input: str | ActivityParams) -> SentActivity: """ - Send a reply quoting a specific message by ID. + Send a message to the conversation with a quoted message reference prepended to the text. + Teams renders the quoted message as a preview bubble above the response text. Args: message_id: The ID of the message to quote - input: The message to send, can be a string or ActivityParams + input: The response text or activity — a quote placeholder for message_id will be prepended to its text Returns: The sent activity @@ -218,7 +219,7 @@ async def quote_reply(self, message_id: str, input: str | ActivityParams) -> Sen """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input if isinstance(activity, MessageActivityInput): - activity.prepend_quoted_reply(message_id) + activity.prepend_quote(message_id) return await self.send(activity) async def next(self) -> None: diff --git a/packages/apps/tests/test_quoted_reply.py b/packages/apps/tests/test_quoted_reply.py index f92c5c08..712888c1 100644 --- a/packages/apps/tests/test_quoted_reply.py +++ b/packages/apps/tests/test_quoted_reply.py @@ -132,7 +132,7 @@ async def test_reply_with_activity_params(self) -> None: class TestActivityContextQuoteReply: - """Tests for ActivityContext.quote_reply() with arbitrary message ID.""" + """Tests for ActivityContext.quote() with arbitrary message ID.""" def _create_activity_context(self) -> ActivityContext[Any]: """Create an ActivityContext for testing.""" @@ -165,10 +165,10 @@ def _create_activity_context(self) -> ActivityContext[Any]: ) @pytest.mark.asyncio - async def test_quote_reply_stamps_entity_with_message_id(self) -> None: - """Test that quote_reply() stamps entity with the provided message ID.""" + async def test_quote_stamps_entity_with_message_id(self) -> None: + """Test that quote() stamps entity with the provided message ID.""" ctx = self._create_activity_context() - await ctx.quote_reply("arbitrary-msg-id", "Quoting this!") + await ctx.quote("arbitrary-msg-id", "Quoting this!") sent_activity = ctx._activity_sender.send.call_args[0][0] assert sent_activity.entities is not None @@ -178,30 +178,30 @@ async def test_quote_reply_stamps_entity_with_message_id(self) -> None: assert entity.quoted_reply.message_id == "arbitrary-msg-id" @pytest.mark.asyncio - async def test_quote_reply_prepends_placeholder(self) -> None: - """Test that quote_reply() prepends the quoted placeholder.""" + async def test_quote_prepends_placeholder(self) -> None: + """Test that quote() prepends the quoted placeholder.""" ctx = self._create_activity_context() - await ctx.quote_reply("msg-xyz", "My reply text") + await ctx.quote("msg-xyz", "My reply text") sent_activity = ctx._activity_sender.send.call_args[0][0] assert sent_activity.text == ' My reply text' @pytest.mark.asyncio - async def test_quote_reply_with_empty_text(self) -> None: - """Test that quote_reply() handles empty text correctly.""" + async def test_quote_with_empty_text(self) -> None: + """Test that quote() handles empty text correctly.""" ctx = self._create_activity_context() activity = MessageActivityInput(text="") - await ctx.quote_reply("msg-xyz", activity) + await ctx.quote("msg-xyz", activity) sent_activity = ctx._activity_sender.send.call_args[0][0] assert sent_activity.text == '' @pytest.mark.asyncio - async def test_quote_reply_with_activity_params(self) -> None: - """Test that quote_reply() works with an ActivityParams input.""" + async def test_quote_with_activity_params(self) -> None: + """Test that quote() works with an ActivityParams input.""" ctx = self._create_activity_context() activity = MessageActivityInput(text="Hello world") - await ctx.quote_reply("msg-xyz", activity) + await ctx.quote("msg-xyz", activity) sent_activity = ctx._activity_sender.send.call_args[0][0] assert sent_activity.text == ' Hello world' From 5efa251be1d4082d369e9b749b8589ffad03f8d2 Mon Sep 17 00:00:00 2001 From: Corina Gum <14900841+corinagum@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:01:22 -0700 Subject: [PATCH 16/16] Add TENANT_ID to sample env vars Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/quoted-replies/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/quoted-replies/README.md b/examples/quoted-replies/README.md index 8928579d..618e09d6 100644 --- a/examples/quoted-replies/README.md +++ b/examples/quoted-replies/README.md @@ -32,4 +32,5 @@ Create a `.env` file: ```env CLIENT_ID= CLIENT_SECRET= +TENANT_ID= ```