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=
```