diff --git a/examples/quoted-replies/README.md b/examples/quoted-replies/README.md new file mode 100644 index 00000000..618e09d6 --- /dev/null +++ b/examples/quoted-replies/README.md @@ -0,0 +1,36 @@ +# 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()` — 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_quote()` + `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 +pip install -e . +python src/main.py + +# Or with uv: +uv run python src/main.py +``` + +## Environment Variables + +Create a `.env` file: + +```env +CLIENT_ID= +CLIENT_SECRET= +TENANT_ID= +``` 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..d6599def --- /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. + +Example: Quoted Replies + +A bot that demonstrates quoted reply features in Microsoft Teams. +Tests reply(), quote(), add_quote(), and get_quoted_messages(). +""" + +import asyncio + +from microsoft_teams.api import MessageActivity, MessageActivityInput +from microsoft_teams.api.activities.typing import TypingActivityInput +from microsoft_teams.apps import ActivityContext, App + +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("Thanks for your message! This reply auto-quotes it using reply().") + return + + # ============================================ + # 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(sent.id, "Just to confirm — does the new time work for everyone?") + return + + # ============================================ + # 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_quote(sent.id, "Done! Left my comments on the PR.") + await ctx.send(msg) + return + + # ============================================ + # Multi-quote with mixed responses + # ============================================ + if "test multi" in text: + 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_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_quote() + add_text() — manual control + # ============================================ + if "test manual" in text: + sent = await ctx.send("Deployment to staging is complete.") + msg = MessageActivityInput().add_quote(sent.id).add_text(" Verified — all smoke tests passing.") + 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() 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_quote() + 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/packages/api/src/microsoft_teams/api/activities/message/message.py b/packages/api/src/microsoft_teams/api/activities/message/message.py index 1b5111a0..f6181122 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,19 @@ 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 this message. + + 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)] + def is_recipient_mentioned(self) -> bool: """ Check if the recipient account is mentioned in the message. @@ -397,6 +412,54 @@ def add_stream_final(self) -> Self: return self.add_entity(stream_entity) + def prepend_quote(self, message_id: str) -> Self: + """ + Prepend a quotedReply entity and placeholder before existing text. + Used by reply()/quote() 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_quote(self, message_id: str, text: str | None = None) -> Self: + """ + 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 ID of the message to quote + text: Optional text, appended to the quoted message placeholder + + 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))) + self.add_text(f'') + if text: + self.add_text(f" {text}") + 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/activity.py b/packages/api/src/microsoft_teams/api/models/activity.py index e021f779..64ef24b4 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 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..19e34d31 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/entity/quoted_reply_entity.py @@ -0,0 +1,57 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +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 + + .. warning:: Preview + This API is in preview and may change in the future. + Diagnostic: ExperimentalTeamsQuotedReplies + """ + + 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 (IC3 epoch value, e.g. '1772050244572'). Inbound only." + + 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" + + +@experimental("ExperimentalTeamsQuotedReplies") +class QuotedReplyEntity(CustomBaseModel): + """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" + + 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..9c34634c --- /dev/null +++ b/packages/api/tests/unit/test_quoted_replies_property.py @@ -0,0 +1,103 @@ +""" +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_quote builder method on MessageActivityInput""" + + def test_add_quote_adds_entity_and_placeholder(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + 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_quote_with_response(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = MessageActivityInput().add_quote("msg-1", "my response") + assert msg.text == ' my response' + + def test_add_quote_multi_quote_interleaved(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = ( + MessageActivityInput() + .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_quote_grouped(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = MessageActivityInput().add_quote("msg-1").add_quote("msg-2", "response to both") + assert msg.text == ' response to both' + + def test_add_quote_chainable_with_add_text(self): + from microsoft_teams.api.activities.message import MessageActivityInput + + msg = MessageActivityInput().add_quote("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..5480c766 --- /dev/null +++ b/packages/api/tests/unit/test_quoted_reply_entity.py @@ -0,0 +1,142 @@ +""" +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="1772050244572", + 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 == "1772050244572" + 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": "1772050244572", + "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="1772050244572", + 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..6397f633 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, @@ -187,13 +187,39 @@ 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 + """ + if self.activity.id: + 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(self, message_id: str, input: str | ActivityParams) -> SentActivity: + """ + 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 response text or activity — a quote placeholder for message_id will be prepended to its text + + 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 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 + activity.prepend_quote(message_id) return await self.send(activity) async def next(self) -> None: @@ -205,31 +231,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_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 new file mode 100644 index 00000000..712888c1 --- /dev/null +++ b/packages/apps/tests/test_quoted_reply.py @@ -0,0 +1,210 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Any, Optional +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: Optional[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", + 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.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' + + +class TestActivityContextQuoteReply: + """Tests for ActivityContext.quote() 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", + 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_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("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_prepends_placeholder(self) -> None: + """Test that quote() prepends the quoted placeholder.""" + ctx = self._create_activity_context() + 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_with_empty_text(self) -> None: + """Test that quote() handles empty text correctly.""" + ctx = self._create_activity_context() + activity = MessageActivityInput(text="") + 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_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("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) 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"