Skip to content
Open
36 changes: 36 additions & 0 deletions examples/quoted-replies/README.md
Original file line number Diff line number Diff line change
@@ -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=<your-azure-bot-app-id>
CLIENT_SECRET=<your-azure-bot-app-secret>
TENANT_ID=<your-tenant-id>
```
14 changes: 14 additions & 0 deletions examples/quoted-replies/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 }
114 changes: 114 additions & 0 deletions examples/quoted-replies/src/main.py
Original file line number Diff line number Diff line change
@@ -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())
63 changes: 63 additions & 0 deletions packages/api/src/microsoft_teams/api/activities/message/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
Importance,
InputHint,
MentionEntity,
QuotedReplyData,
QuotedReplyEntity,
StreamInfoEntity,
SuggestedActions,
TextFormat,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'<quoted messageId="{message_id}"/>'
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'<quoted messageId="{message_id}"/>')
if text:
self.add_text(f" {text}")
return self

def with_recipient(self, value: Account, is_targeted: Optional[bool] = None) -> Self:
"""
Set the recipient.
Expand Down
5 changes: 0 additions & 5 deletions packages/api/src/microsoft_teams/api/models/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -34,6 +35,8 @@
"MentionEntity",
"MessageEntity",
"ProductInfoEntity",
"QuotedReplyData",
"QuotedReplyEntity",
"SensitiveUsageEntity",
"SensitiveUsage",
"SensitiveUsagePattern",
Expand Down
2 changes: 2 additions & 0 deletions packages/api/src/microsoft_teams/api/models/entity/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,4 +24,5 @@
CitationEntity,
SensitiveUsageEntity,
ProductInfoEntity,
QuotedReplyEntity,
]
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 0 additions & 2 deletions packages/api/tests/unit/test_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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
Expand Down
Loading
Loading