diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py
index f2e3de26..c4274cfd 100644
--- a/examples/targeted-messages/src/main.py
+++ b/examples/targeted-messages/src/main.py
@@ -4,9 +4,9 @@
"""
import asyncio
+from datetime import datetime, timezone
-from microsoft_teams.api import Account, MessageActivity, MessageActivityInput
-from microsoft_teams.api.activities.typing import TypingActivityInput
+from microsoft_teams.api import MessageActivity, MessageActivityInput
from microsoft_teams.apps import ActivityContext, App
"""
@@ -23,112 +23,93 @@
@app.on_message
async def handle_message(ctx: ActivityContext[MessageActivity]):
"""Handle message activities."""
- await ctx.reply(TypingActivityInput())
+ activity = ctx.activity
+ text = (activity.text or "").lower()
- text = (ctx.activity.text or "").lower()
+ print(f"[MESSAGE] Received: {text}")
- # ============================================
- # Test targeted SEND (create)
- # ============================================
- if "test send" in text:
- members = await ctx.api.conversations.members(ctx.activity.conversation.id).get_all()
+ if "test update" in text:
+ # UPDATE: Send a targeted message, then update it after 3 seconds
+ conversation_id = activity.conversation.id
- for member in members:
- print(f"Member: {member.name} - {member.id}")
+ targeted_message = MessageActivityInput(
+ text="📝 This message will be **updated** in 3 seconds..."
+ ).with_recipient(activity.from_, is_targeted=True)
- targeted_message = MessageActivityInput(
- text="🔒 [SEND] This is a targeted message - only YOU can see this!"
- ).with_recipient(Account(id=member.id, name=member.name), is_targeted=True)
+ result = await ctx.send(targeted_message)
- result = await ctx.send(targeted_message)
- print("[SEND] Sent targeted message")
- return
-
- # ============================================
- # Test targeted REPLY
- # ============================================
- if "test reply" in text:
- targeted_reply = MessageActivityInput(text="🔒 [REPLY] Targeted reply - only YOU can see this!").with_recipient(
- ctx.activity.from_, is_targeted=True
- )
+ if result.id:
+ message_id = result.id
- result = await ctx.reply(targeted_reply)
- print(f"Targeted REPLY result: {result}")
- return
+ async def update_after_delay():
+ await asyncio.sleep(3)
+ try:
+ timestamp = datetime.now(timezone.utc).strftime("%H:%M:%S")
+ updated_message = MessageActivityInput(
+ text=f"✏️ **Updated!** This message was modified at {timestamp}"
+ )
+ updated_message.id = message_id
- # ============================================
- # Test targeted UPDATE
- # ============================================
- if "test update" in text:
- # First send a targeted message
- targeted_message = MessageActivityInput(text="🔒 [UPDATE] Original targeted message...").with_recipient(
- ctx.activity.from_, is_targeted=True
- )
+ await ctx.api.conversations.activities(conversation_id).update_targeted(message_id, updated_message)
+ print("[UPDATE] Updated targeted message")
+ except Exception as err:
+ print(f"[UPDATE] Error: {err}")
+
+ asyncio.create_task(update_after_delay())
+
+ print("[UPDATE] Scheduled update in 3 seconds")
+
+ elif "test delete" in text:
+ # DELETE: Send a targeted message, then delete it after 3 seconds
+ conversation_id = activity.conversation.id
- result = await ctx.send(targeted_message)
- print(f"Initial targeted message ID: {result.id}")
-
- # Wait then update
- async def update_after_delay():
- await asyncio.sleep(3)
- try:
- # For targeted updates, do not include recipient in the payload.
- updated_message = MessageActivityInput(
- text="🔒 [UPDATE] ✅ UPDATED targeted message! (only you see this)"
- )
- updated_message.id = result.id
-
- await ctx.api.conversations.activities(ctx.activity.conversation.id).update_targeted(
- result.id, updated_message
- )
- print("Targeted UPDATE completed")
- except Exception as err:
- print(f"Targeted UPDATE error: {err}")
-
- asyncio.create_task(update_after_delay())
- return
-
- # ============================================
- # Test targeted DELETE
- # ============================================
- if "test delete" in text:
- # First send a targeted message
targeted_message = MessageActivityInput(
- text="🔒 [DELETE] This targeted message will be DELETED in 5 seconds..."
- ).with_recipient(ctx.activity.from_, is_targeted=True)
+ text="🗑️ This message will be **deleted** in 3 seconds..."
+ ).with_recipient(activity.from_, is_targeted=True)
result = await ctx.send(targeted_message)
- print(f"Targeted message to delete, ID: {result.id}")
-
- # Wait then delete using the targeted API
- async def delete_after_delay():
- await asyncio.sleep(5)
- try:
- await ctx.api.conversations.activities(ctx.activity.conversation.id).delete_targeted(result.id)
- print("Targeted DELETE completed")
- except Exception as err:
- print(f"Targeted DELETE error: {err}")
-
- asyncio.create_task(delete_after_delay())
- return
-
- # ============================================
- # Help / Default
- # ============================================
- if "help" in text:
- await ctx.reply(
- "**Targeted Messages Test Bot**\n\n"
+
+ if result.id:
+ message_id = result.id
+
+ async def delete_after_delay():
+ await asyncio.sleep(3)
+ try:
+ await ctx.api.conversations.activities(conversation_id).delete_targeted(message_id)
+ print("[DELETE] Deleted targeted message")
+ except Exception as err:
+ print(f"[DELETE] Error: {err}")
+
+ asyncio.create_task(delete_after_delay())
+
+ print("[DELETE] Scheduled delete in 3 seconds")
+
+ elif "test public" in text:
+ # PUBLIC: Send a public message visible to everyone in the chat.
+ await ctx.send(MessageActivityInput(text="📋 Here is the public result — everyone can see this!"))
+ print("[PUBLIC] Sent public message")
+
+ elif "test send" in text:
+ # SEND: Send a targeted message visible only to the sender.
+ targeted_message = MessageActivityInput(
+ text="👋 This is a **targeted message** — only YOU can see this!"
+ ).with_recipient(activity.from_, is_targeted=True)
+ await ctx.send(targeted_message)
+ print("[SEND] Sent targeted message")
+
+ elif "help" in text:
+ await ctx.send(
+ "**🎯 Targeted Messages Demo**\n\n"
"**Commands:**\n"
- "- `test send` - Send a targeted message\n"
- "- `test reply` - Reply with a targeted message\n"
- "- `test update` - Send then update a targeted message\n"
- "- `test delete` - Send then delete a targeted message\n\n"
- "💡 *Test in a group chat to verify others don't see targeted messages!*"
+ "- `test send` - Send a targeted message (only visible to you)\n"
+ "- `test update` - Send a targeted message, then update it after 3 seconds\n"
+ "- `test delete` - Send a targeted message, then delete it after 3 seconds\n"
+ "- `test public` - Send a public reply (visible to all)\n\n"
+ "_Targeted messages are only visible to you, even in group chats!_"
)
- return
- # Default
- await ctx.reply('Say "help" for available commands.')
+ else:
+ await ctx.send(f"You said: '{activity.text}'\n\nType `help` to see available commands.")
if __name__ == "__main__":
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 e94e0f9a..9493bf39 100644
--- a/packages/api/src/microsoft_teams/api/activities/message/message.py
+++ b/packages/api/src/microsoft_teams/api/activities/message/message.py
@@ -35,6 +35,7 @@
Entity,
Image,
MessageEntity,
+ TargetedMessageInfoEntity,
)
from ..utils import StripMentionsTextOptions, strip_mentions_text
@@ -481,6 +482,36 @@ def add_quote(self, message_id: str, text: str | None = None) -> Self:
self.add_text(f" {text}")
return self
+ @experimental("ExperimentalTeamsTargeted")
+ def add_targeted_message_info(self, message_id: str) -> Self:
+ """Add a targetedMessageInfo entity for prompt preview.
+
+ If an entity with type ``"targetedMessageInfo"`` already exists,
+ it is not added again (one prompt preview per message).
+
+ When adding the entity, any ``quotedReply`` entities and matching
+ ```` placeholder text are removed to avoid
+ collision with prompt preview.
+
+ Args:
+ message_id: The message ID of the targeted message.
+
+ Returns:
+ Self for method chaining
+ """
+ has_entity = any(isinstance(e, TargetedMessageInfoEntity) for e in (self.entities or []))
+
+ # Always strip quotedReply artifacts to avoid collision with prompt preview,
+ # if the developer already attached a targetedMessageInfo entity.
+ if self.entities is not None:
+ self.entities = [e for e in self.entities if getattr(e, "type", None) != "quotedReply"]
+ if self.text is not None:
+ self.text = self.text.replace(f'', "").strip()
+
+ if not has_entity:
+ self.add_entity(TargetedMessageInfoEntity(message_id=message_id))
+ 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 473c46b7..47bcdf37 100644
--- a/packages/api/src/microsoft_teams/api/models/entity/__init__.py
+++ b/packages/api/src/microsoft_teams/api/models/entity/__init__.py
@@ -21,6 +21,7 @@
from .quoted_reply_entity import QuotedReplyData, QuotedReplyEntity
from .sensitive_usage_entity import SensitiveUsage, SensitiveUsageEntity, SensitiveUsagePattern
from .stream_info_entity import StreamInfoEntity
+from .targeted_message_info_entity import TargetedMessageInfoEntity
__all__ = [
"AIMessageEntity",
@@ -41,5 +42,6 @@
"SensitiveUsage",
"SensitiveUsagePattern",
"StreamInfoEntity",
+ "TargetedMessageInfoEntity",
"Entity",
]
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 b89ce533..0488c4aa 100644
--- a/packages/api/src/microsoft_teams/api/models/entity/entity.py
+++ b/packages/api/src/microsoft_teams/api/models/entity/entity.py
@@ -14,6 +14,7 @@
from .quoted_reply_entity import QuotedReplyEntity
from .sensitive_usage_entity import SensitiveUsageEntity
from .stream_info_entity import StreamInfoEntity
+from .targeted_message_info_entity import TargetedMessageInfoEntity
Entity = Union[
ClientInfoEntity,
@@ -25,4 +26,5 @@
SensitiveUsageEntity,
ProductInfoEntity,
QuotedReplyEntity,
+ TargetedMessageInfoEntity,
]
diff --git a/packages/api/src/microsoft_teams/api/models/entity/targeted_message_info_entity.py b/packages/api/src/microsoft_teams/api/models/entity/targeted_message_info_entity.py
new file mode 100644
index 00000000..aa97e57e
--- /dev/null
+++ b/packages/api/src/microsoft_teams/api/models/entity/targeted_message_info_entity.py
@@ -0,0 +1,26 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+
+from typing import Literal
+
+from microsoft_teams.common.experimental import experimental
+
+from ..custom_base_model import CustomBaseModel
+
+
+@experimental("ExperimentalTeamsTargeted")
+class TargetedMessageInfoEntity(CustomBaseModel):
+ """Entity containing targeted message information for prompt preview.
+
+ .. warning:: Preview
+ This class is in preview and may change in the future.
+ Diagnostic: ExperimentalTeamsTargeted
+ """
+
+ type: Literal["targetedMessageInfo"] = "targetedMessageInfo"
+ "Type identifier for targeted message info"
+
+ message_id: str
+ "The ID of the targeted message this activity is replying to"
diff --git a/packages/api/tests/unit/test_targeted_message_info_entity.py b/packages/api/tests/unit/test_targeted_message_info_entity.py
new file mode 100644
index 00000000..82c35da2
--- /dev/null
+++ b/packages/api/tests/unit/test_targeted_message_info_entity.py
@@ -0,0 +1,109 @@
+"""
+Copyright (c) Microsoft Corporation. All rights reserved.
+Licensed under the MIT License.
+"""
+# pyright: basic
+
+import pytest
+from microsoft_teams.api import MessageActivityInput
+from microsoft_teams.api.models.entity.targeted_message_info_entity import TargetedMessageInfoEntity
+
+
+@pytest.mark.unit
+class TestTargetedMessageInfoEntity:
+ """Unit tests for TargetedMessageInfoEntity."""
+
+ def test_default_type(self) -> None:
+ entity = TargetedMessageInfoEntity(message_id="1772129782775")
+ assert entity.type == "targetedMessageInfo"
+
+ def test_message_id(self) -> None:
+ entity = TargetedMessageInfoEntity(message_id="1772129782775")
+ assert entity.message_id == "1772129782775"
+
+ def test_serialization_camel_case(self) -> None:
+ entity = TargetedMessageInfoEntity(message_id="1772129782775")
+ data = entity.model_dump(by_alias=True, exclude_none=True)
+ assert data == {
+ "type": "targetedMessageInfo",
+ "messageId": "1772129782775",
+ }
+
+ def test_deserialization_camel_case(self) -> None:
+ entity = TargetedMessageInfoEntity.model_validate(
+ {
+ "type": "targetedMessageInfo",
+ "messageId": "1772129782775",
+ }
+ )
+ assert entity.type == "targetedMessageInfo"
+ assert entity.message_id == "1772129782775"
+
+
+@pytest.mark.unit
+class TestAddTargetedMessageInfo:
+ """Tests for MessageActivityInput.add_targeted_message_info including QR collision guard."""
+
+ def test_adds_entity(self) -> None:
+ activity = MessageActivityInput(text="test")
+ activity.add_targeted_message_info("12345")
+
+ targeted = [e for e in (activity.entities or []) if isinstance(e, TargetedMessageInfoEntity)]
+ assert len(targeted) == 1
+ assert targeted[0].message_id == "12345"
+
+ def test_does_not_duplicate_when_entity_exists(self) -> None:
+ activity = MessageActivityInput(text="test")
+ activity.add_entity(TargetedMessageInfoEntity(message_id="9999"))
+ activity.add_targeted_message_info("12345")
+
+ targeted = [e for e in (activity.entities or []) if isinstance(e, TargetedMessageInfoEntity)]
+ assert len(targeted) == 1
+ assert targeted[0].message_id == "9999"
+
+ def test_strips_qr_even_when_entity_already_exists(self) -> None:
+ """When developer pre-attaches targetedMessageInfo and reply() adds quotedReply,
+ add_targeted_message_info should still strip the quotedReply artifacts."""
+ from unittest.mock import MagicMock
+
+ qr_entity = MagicMock()
+ qr_entity.type = "quotedReply"
+
+ activity = MessageActivityInput(text=' Here is my reply')
+ activity.add_entity(TargetedMessageInfoEntity(message_id="9999"))
+ activity.entities.append(qr_entity) # type: ignore[arg-type]
+
+ activity.add_targeted_message_info("12345")
+
+ # Should not duplicate the entity
+ targeted = [e for e in (activity.entities or []) if isinstance(e, TargetedMessageInfoEntity)]
+ assert len(targeted) == 1
+ assert targeted[0].message_id == "9999"
+ # Should still strip quotedReply
+ assert not any(getattr(e, "type", None) == "quotedReply" for e in (activity.entities or []))
+ assert activity.text == "Here is my reply"
+
+ def test_strips_quoted_reply_entities(self) -> None:
+ activity = MessageActivityInput(text="test", entities=[])
+ # Simulate a quotedReply entity (generic entity with type="quotedReply")
+
+ # Use a mock-like approach: create a simple object with type="quotedReply"
+ from unittest.mock import MagicMock
+
+ qr_entity = MagicMock()
+ qr_entity.type = "quotedReply"
+ activity.entities = [qr_entity] # type: ignore[list-item]
+
+ activity.add_targeted_message_info("12345")
+
+ assert not any(getattr(e, "type", None) == "quotedReply" for e in (activity.entities or []))
+ targeted = [e for e in (activity.entities or []) if isinstance(e, TargetedMessageInfoEntity)]
+ assert len(targeted) == 1
+
+ def test_strips_quoted_placeholder_from_text(self) -> None:
+ activity = MessageActivityInput(text=' Here is my reply')
+ activity.add_targeted_message_info("12345")
+
+ assert activity.text == "Here is my reply"
+ targeted = [e for e in (activity.entities or []) if isinstance(e, TargetedMessageInfoEntity)]
+ assert len(targeted) == 1
diff --git a/packages/apps/src/microsoft_teams/apps/activity_sender.py b/packages/apps/src/microsoft_teams/apps/activity_sender.py
index 78d02ae8..1ab97c96 100644
--- a/packages/apps/src/microsoft_teams/apps/activity_sender.py
+++ b/packages/apps/src/microsoft_teams/apps/activity_sender.py
@@ -47,6 +47,15 @@ async def send(self, activity: ActivityParams, ref: ConversationReference) -> Se
Returns:
The sent activity with id and other server-populated fields
"""
+ is_targeted = (
+ isinstance(activity, MessageActivityInput)
+ and activity.recipient is not None
+ and activity.recipient.is_targeted is True
+ )
+
+ if is_targeted and ref.conversation.conversation_type == "personal":
+ raise ValueError("Targeted messages are not supported in 1:1 (personal) chats.")
+
# Create API client for this conversation's service URL
api = ApiClient(service_url=ref.service_url, options=self._client)
@@ -54,11 +63,6 @@ async def send(self, activity: ActivityParams, ref: ConversationReference) -> Se
activity.from_ = ref.bot
activity.conversation = ref.conversation
- is_targeted = (
- isinstance(activity, MessageActivityInput)
- and activity.recipient is not None
- and activity.recipient.is_targeted is True
- )
is_update = hasattr(activity, "id") and activity.id
activities = api.conversations.activities(ref.conversation.id)
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 ba9d3155..4fceaf68 100644
--- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py
+++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py
@@ -6,6 +6,7 @@
import base64
import json
import logging
+import warnings
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Optional, TypeVar
@@ -35,7 +36,7 @@
from microsoft_teams.api.models.oauth import OAuthCard
from microsoft_teams.cards import AdaptiveCard
from microsoft_teams.common import Storage
-from microsoft_teams.common.experimental import experimental
+from microsoft_teams.common.experimental import ExperimentalWarning, experimental
from microsoft_teams.common.http.client_token import Token
from ..activity_sender import ActivitySender
@@ -182,6 +183,8 @@ async def send(
else:
activity = message
+ self._add_targeted_message_info_entity(activity)
+
ref = conversation_ref or self.conversation_ref
res = await self._activity_sender.send(activity, ref)
return res
@@ -229,6 +232,31 @@ def set_next(self, handler: Callable[[], Awaitable[None]]) -> None:
"""Set the next handler in the middleware chain."""
self._next_handler = handler
+ def _is_incoming_targeted(self) -> bool:
+ """Check if the incoming activity is a targeted message."""
+ activity = self.activity
+ return (
+ hasattr(activity, "recipient")
+ and hasattr(activity.recipient, "is_targeted")
+ and activity.recipient.is_targeted is True
+ )
+
+ def _add_targeted_message_info_entity(self, activity_params: ActivityParams) -> None:
+ """Auto-populate targetedMessageInfo entity when replying to a targeted message.
+
+ In the reactive flow, the SDK reads the incoming targeted message ID
+ and attaches the entity automatically so the developer doesn't need to.
+ Skips if the developer already attached a targetedMessageInfo entity.
+ """
+ if not self._is_incoming_targeted():
+ return
+ if not isinstance(activity_params, MessageActivityInput):
+ return
+
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", ExperimentalWarning)
+ activity_params.add_targeted_message_info(self.activity.id)
+
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 ba476682..263878a9 100644
--- a/packages/apps/tests/test_activity_context.py
+++ b/packages/apps/tests/test_activity_context.py
@@ -9,7 +9,8 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
-from microsoft_teams.api import Account, MessageActivityInput, SentActivity
+from microsoft_teams.api import Account, MessageActivityInput, SentActivity, TargetedMessageInfoEntity
+from microsoft_teams.api.activities.typing import TypingActivityInput
from microsoft_teams.api.auth.cloud_environment import PUBLIC
from microsoft_teams.api.models.entity import QuotedReplyEntity
from microsoft_teams.apps.routing.activity_context import ActivityContext
@@ -530,3 +531,96 @@ async def test_sign_out_logs_error_and_does_not_raise_on_failure(self) -> None:
mock_log_error.assert_called_once()
logged_message = mock_log_error.call_args[0][0]
assert "Failed to sign out user" in logged_message
+
+
+class TestActivityContextPromptPreview:
+ """Tests for reactive auto-population of targetedMessageInfo entity."""
+
+ def _make_targeted_activity(self, activity_id: str = "1772129782775") -> MagicMock:
+ mock_activity = MagicMock()
+ mock_activity.type = "message"
+ mock_activity.id = activity_id
+ mock_activity.text = "Hello from slash command"
+ mock_activity.from_ = Account(id="user-123", name="Test User")
+ mock_activity.recipient = Account(id="bot-456", name="Bot", is_targeted=True)
+ return mock_activity
+
+ def _make_non_targeted_activity(self) -> MagicMock:
+ mock_activity = MagicMock()
+ mock_activity.type = "message"
+ mock_activity.id = "normal-msg-id"
+ mock_activity.text = "Normal message"
+ mock_activity.from_ = Account(id="user-123", name="Test User")
+ mock_activity.recipient = Account(id="bot-456", name="Bot")
+ return mock_activity
+
+ @pytest.mark.asyncio
+ async def test_send_auto_adds_targeted_message_info_entity(self) -> None:
+ """When replying to a targeted message, the SDK auto-adds targetedMessageInfo."""
+ activity = self._make_targeted_activity("1772129782775")
+ ctx, mock_sender = _create_activity_context(activity=activity)
+
+ await ctx.send("Here is your agenda")
+
+ sent_activity = mock_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, TargetedMessageInfoEntity)
+ assert entity.message_id == "1772129782775"
+ assert entity.type == "targetedMessageInfo"
+
+ @pytest.mark.asyncio
+ async def test_send_does_not_add_entity_for_non_targeted(self) -> None:
+ """When replying to a normal message, no targetedMessageInfo is added."""
+ activity = self._make_non_targeted_activity()
+ ctx, mock_sender = _create_activity_context(activity=activity)
+
+ await ctx.send("Normal reply")
+
+ sent_activity = mock_sender.send.call_args[0][0]
+ assert sent_activity.entities is None
+
+ @pytest.mark.asyncio
+ async def test_send_does_not_duplicate_entity_if_already_present(self) -> None:
+ """If the developer already added targetedMessageInfo, the SDK does not duplicate it."""
+ activity = self._make_targeted_activity("1772129782775")
+ ctx, mock_sender = _create_activity_context(activity=activity)
+
+ msg = MessageActivityInput(text="Reply").add_entity(TargetedMessageInfoEntity(message_id="custom-id"))
+ await ctx.send(msg)
+
+ sent_activity = mock_sender.send.call_args[0][0]
+ assert sent_activity.entities is not None
+ assert len(sent_activity.entities) == 1
+ assert sent_activity.entities[0].message_id == "custom-id"
+
+ @pytest.mark.asyncio
+ async def test_reply_auto_adds_targeted_message_info_entity(self) -> None:
+ """reply() also auto-adds targetedMessageInfo for targeted messages.
+ The blockquote is added by reply(), then stripped by add_targeted_message_info
+ in send() to avoid collision with prompt preview."""
+ activity = self._make_targeted_activity("1772129782775")
+ ctx, mock_sender = _create_activity_context(activity=activity)
+
+ await ctx.reply("Reply with prompt preview")
+
+ sent_activity = mock_sender.send.call_args[0][0]
+ assert sent_activity.entities is not None
+ targeted_entities = [e for e in sent_activity.entities if isinstance(e, TargetedMessageInfoEntity)]
+ assert len(targeted_entities) == 1
+ assert targeted_entities[0].message_id == "1772129782775"
+
+ # quotedReply entities should be stripped by add_targeted_message_info
+ assert not any(getattr(e, "type", None) == "quotedReply" for e in sent_activity.entities)
+
+ @pytest.mark.asyncio
+ async def test_send_does_not_add_entity_for_non_message_activity(self) -> None:
+ """Non-message activities (e.g. typing) should not get targetedMessageInfo attached."""
+ activity = self._make_targeted_activity("1772129782775")
+ ctx, mock_sender = _create_activity_context(activity=activity)
+
+ await ctx.send(TypingActivityInput())
+
+ sent_activity = mock_sender.send.call_args[0][0]
+ assert sent_activity.entities is None
diff --git a/packages/apps/tests/test_activity_sender.py b/packages/apps/tests/test_activity_sender.py
index 384edbb9..59965934 100644
--- a/packages/apps/tests/test_activity_sender.py
+++ b/packages/apps/tests/test_activity_sender.py
@@ -36,6 +36,16 @@ def conversation_ref(self):
service_url="https://test.service.url",
)
+ @pytest.fixture
+ def group_conversation_ref(self):
+ """Create a group chat conversation reference for testing."""
+ return ConversationReference(
+ bot=Account(id="bot-123", name="Test Bot", role="bot"),
+ conversation=ConversationAccount(id="conv-789", conversation_type="groupChat"),
+ channel_id="msteams",
+ service_url="https://test.service.url",
+ )
+
def _create_sent_activity(self, activity, activity_id="msg-123"):
"""Helper to create a proper SentActivity mock."""
return SentActivity(id=activity_id, activity_params=activity)
@@ -94,7 +104,7 @@ async def test_send_sets_from_and_conversation(self, sender, conversation_ref):
assert activity.conversation == conversation_ref.conversation
@pytest.mark.asyncio
- async def test_send_targeted_message_calls_create_targeted(self, sender, conversation_ref):
+ async def test_send_targeted_message_calls_create_targeted(self, sender, group_conversation_ref):
"""Test that targeted messages use the create_targeted method."""
recipient = Account(id="user-123", name="Test User", role="user")
activity = MessageActivityInput(text="Hello").with_recipient(recipient, is_targeted=True)
@@ -107,7 +117,7 @@ async def test_send_targeted_message_calls_create_targeted(self, sender, convers
mock_api.conversations.activities.return_value = mock_activities
mock_api_client.return_value = mock_api
- await sender.send(activity, conversation_ref)
+ await sender.send(activity, group_conversation_ref)
mock_activities.create_targeted.assert_called_once_with(activity)
mock_activities.create.assert_not_called()
@@ -131,7 +141,7 @@ async def test_send_non_targeted_message_does_not_call_create_targeted(self, sen
mock_activities.create_targeted.assert_not_called()
@pytest.mark.asyncio
- async def test_update_targeted_message_calls_update_targeted(self, sender, conversation_ref):
+ async def test_update_targeted_message_calls_update_targeted(self, sender, group_conversation_ref):
"""Test that targeted message updates use the update_targeted method."""
activity = MessageActivityInput(text="Updated targeted message")
activity.recipient = Account(id="user-123", name="Test User", role="user", is_targeted=True)
@@ -147,7 +157,7 @@ async def test_update_targeted_message_calls_update_targeted(self, sender, conve
mock_api.conversations.activities.return_value = mock_activities
mock_api_client.return_value = mock_api
- await sender.send(activity, conversation_ref)
+ await sender.send(activity, group_conversation_ref)
mock_activities.update_targeted.assert_called_once_with("existing-msg-id", activity)
mock_activities.update.assert_not_called()
@@ -170,3 +180,32 @@ async def test_update_non_targeted_message_calls_update(self, sender, conversati
mock_activities.update.assert_called_once_with("existing-msg-id", activity)
mock_activities.update_targeted.assert_not_called()
+
+ @pytest.mark.asyncio
+ async def test_send_targeted_in_personal_chat_raises(self, sender, conversation_ref):
+ """Test that sending a targeted message in a personal (1:1) chat raises ValueError."""
+ activity = MessageActivityInput(text="Hello").with_recipient(
+ Account(id="user-1", name="User"), is_targeted=True
+ )
+
+ with pytest.raises(ValueError, match="Targeted messages are not supported in 1:1"):
+ await sender.send(activity, conversation_ref)
+
+ @pytest.mark.asyncio
+ async def test_send_targeted_in_group_chat_succeeds(self, sender, group_conversation_ref):
+ """Test that sending a targeted message in a group chat proceeds normally."""
+ activity = MessageActivityInput(text="Hello").with_recipient(
+ Account(id="user-1", name="User"), is_targeted=True
+ )
+
+ mock_activities = MagicMock()
+ mock_activities.create_targeted = AsyncMock(return_value=self._create_sent_activity(activity))
+
+ with patch("microsoft_teams.apps.activity_sender.ApiClient") as mock_api_client:
+ mock_api = MagicMock()
+ mock_api.conversations.activities.return_value = mock_activities
+ mock_api_client.return_value = mock_api
+
+ await sender.send(activity, group_conversation_ref)
+
+ mock_activities.create_targeted.assert_called_once()