From 6842a78316d35793e0afa5db14339081048d0684 Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Tue, 21 Apr 2026 12:53:12 +0000 Subject: [PATCH 1/5] Prompt Preview Support --- examples/targeted-messages/src/main.py | 36 +++++++- .../api/models/entity/__init__.py | 2 + .../api/models/entity/entity.py | 2 + .../entity/targeted_message_info_entity.py | 18 ++++ .../unit/test_targeted_message_info_entity.py | 39 +++++++++ .../apps/routing/activity_context.py | 20 +++++ packages/apps/tests/test_activity_context.py | 83 +++++++++++++++++++ 7 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/microsoft_teams/api/models/entity/targeted_message_info_entity.py create mode 100644 packages/api/tests/unit/test_targeted_message_info_entity.py diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py index f2e3de26..ffc5da59 100644 --- a/examples/targeted-messages/src/main.py +++ b/examples/targeted-messages/src/main.py @@ -5,7 +5,7 @@ import asyncio -from microsoft_teams.api import Account, MessageActivity, MessageActivityInput +from microsoft_teams.api import Account, MessageActivity, MessageActivityInput, TargetedMessageInfoEntity from microsoft_teams.api.activities.typing import TypingActivityInput from microsoft_teams.apps import ActivityContext, App @@ -122,11 +122,43 @@ async def delete_after_delay(): "- `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 delete` - Send then delete a targeted message\n" + "- `test prompt preview` - Reply publicly with prompt preview (reactive)\n" + "- `test proactive preview` - Reply publicly with prompt preview (proactive)\n\n" "💡 *Test in a group chat to verify others don't see targeted messages!*" ) return + # ============================================ + # Test Prompt Preview — Reactive flow + # The SDK auto-populates targetedMessageInfo + # when the incoming activity is a targeted message. + # ============================================ + if "test prompt preview" in text: + # Use send() instead of reply() to avoid a duplicate blockquote — + # the prompt preview card from APX already provides context. + targeted_reply = MessageActivityInput( + text="🔒 [PROMPT PREVIEW] Reactive — SDK auto-attaches targetedMessageInfo!" + ).with_recipient(ctx.activity.from_, is_targeted=True) + await ctx.send(targeted_reply) + return + + # ============================================ + # Test Prompt Preview — Proactive flow + # The developer manually attaches targetedMessageInfo + # using add_entity(). + # ============================================ + if "test proactive preview" in text: + targeted_reply = ( + MessageActivityInput( + text="🔒 [PROMPT PREVIEW] Proactive — developer manually attaches targetedMessageInfo!" + ) + .with_recipient(ctx.activity.from_, is_targeted=True) + .add_entity(TargetedMessageInfoEntity(message_id=ctx.activity.id)) + ) + await ctx.send(targeted_reply) + return + # Default await ctx.reply('Say "help" for available commands.') 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..f3f1b1ed 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/__init__.py +++ b/packages/api/src/microsoft_teams/api/models/entity/__init__.py @@ -20,6 +20,7 @@ from .product_info_entity import ProductInfoEntity from .sensitive_usage_entity import SensitiveUsage, SensitiveUsageEntity, SensitiveUsagePattern from .stream_info_entity import StreamInfoEntity +from .targeted_message_info_entity import TargetedMessageInfoEntity __all__ = [ "AIMessageEntity", @@ -38,5 +39,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 5af75dd2..510cd167 100644 --- a/packages/api/src/microsoft_teams/api/models/entity/entity.py +++ b/packages/api/src/microsoft_teams/api/models/entity/entity.py @@ -13,6 +13,7 @@ from .product_info_entity import ProductInfoEntity from .sensitive_usage_entity import SensitiveUsageEntity from .stream_info_entity import StreamInfoEntity +from .targeted_message_info_entity import TargetedMessageInfoEntity Entity = Union[ ClientInfoEntity, @@ -23,4 +24,5 @@ CitationEntity, SensitiveUsageEntity, ProductInfoEntity, + 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..f02f5d92 --- /dev/null +++ b/packages/api/src/microsoft_teams/api/models/entity/targeted_message_info_entity.py @@ -0,0 +1,18 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import Literal + +from ..custom_base_model import CustomBaseModel + + +class TargetedMessageInfoEntity(CustomBaseModel): + """Entity containing targeted message information for prompt preview.""" + + 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..fd93cb0a --- /dev/null +++ b/packages/api/tests/unit/test_targeted_message_info_entity.py @@ -0,0 +1,39 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" +# pyright: basic + +import pytest +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" 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 d0ae3cac..9996cca3 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -23,6 +23,7 @@ MessageActivityInput, SentActivity, SignOutUserParams, + TargetedMessageInfoEntity, TokenExchangeResource, TokenExchangeState, TokenPostResource, @@ -178,6 +179,8 @@ async def send( else: activity = message + self._auto_add_targeted_message_info(activity) + ref = conversation_ref or self.conversation_ref res = await self._activity_sender.send(activity, ref) return res @@ -231,6 +234,23 @@ def _build_block_quote_for_activity(self) -> Optional[str]: ) return None + def _auto_add_targeted_message_info(self, activity: 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. + """ + incoming = self.activity + if not (hasattr(incoming, "recipient") and hasattr(incoming.recipient, "is_targeted")): + return + if incoming.recipient.is_targeted is not True: + return + + already_has = activity.entities and any(isinstance(e, TargetedMessageInfoEntity) for e in activity.entities) + if not already_has: + activity.add_entity(TargetedMessageInfoEntity(message_id=incoming.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 4e4fa429..3089e8a8 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -387,3 +387,86 @@ 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.""" + from microsoft_teams.api import TargetedMessageInfoEntity + + 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.""" + from microsoft_teams.api import TargetedMessageInfoEntity + + 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.""" + from microsoft_teams.api import TargetedMessageInfoEntity + + 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" From 1b142c4c96f990b64790405317a2ff44d87e7f32 Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:24:23 +0000 Subject: [PATCH 2/5] address comments --- examples/targeted-messages/src/main.py | 8 ++++---- .../apps/routing/activity_context.py | 2 ++ packages/apps/tests/test_activity_context.py | 13 +++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py index ffc5da59..d1fc38fb 100644 --- a/examples/targeted-messages/src/main.py +++ b/examples/targeted-messages/src/main.py @@ -123,8 +123,8 @@ async def delete_after_delay(): "- `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" - "- `test prompt preview` - Reply publicly with prompt preview (reactive)\n" - "- `test proactive preview` - Reply publicly with prompt preview (proactive)\n\n" + "- `test prompt preview` - Reply with targeted message + prompt preview (reactive)\n" + "- `test proactive preview` - Reply with targeted message + prompt preview (proactive)\n\n" "💡 *Test in a group chat to verify others don't see targeted messages!*" ) return @@ -135,8 +135,8 @@ async def delete_after_delay(): # when the incoming activity is a targeted message. # ============================================ if "test prompt preview" in text: - # Use send() instead of reply() to avoid a duplicate blockquote — - # the prompt preview card from APX already provides context. + # Send a targeted reply — the SDK auto-attaches the + # targetedMessageInfo entity targeted_reply = MessageActivityInput( text="🔒 [PROMPT PREVIEW] Reactive — SDK auto-attaches targetedMessageInfo!" ).with_recipient(ctx.activity.from_, is_targeted=True) 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 9996cca3..87bc4344 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -246,6 +246,8 @@ def _auto_add_targeted_message_info(self, activity: ActivityParams) -> None: return if incoming.recipient.is_targeted is not True: return + if not isinstance(activity, MessageActivityInput): + return already_has = activity.entities and any(isinstance(e, TargetedMessageInfoEntity) for e in activity.entities) if not already_has: diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index 3089e8a8..eaac8b1e 100644 --- a/packages/apps/tests/test_activity_context.py +++ b/packages/apps/tests/test_activity_context.py @@ -470,3 +470,16 @@ async def test_reply_auto_adds_targeted_message_info_entity(self) -> 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" + + @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.""" + from microsoft_teams.api.activities.typing import TypingActivityInput + + 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 From 807e9904956edc129848cfd218907e979e191c15 Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:31:45 +0000 Subject: [PATCH 3/5] address comments --- .../apps/src/microsoft_teams/apps/routing/activity_context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 87bc4344..05e19cad 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -249,7 +249,7 @@ def _auto_add_targeted_message_info(self, activity: ActivityParams) -> None: if not isinstance(activity, MessageActivityInput): return - already_has = activity.entities and any(isinstance(e, TargetedMessageInfoEntity) for e in activity.entities) + already_has = any(isinstance(e, TargetedMessageInfoEntity) for e in (activity.entities or [])) if not already_has: activity.add_entity(TargetedMessageInfoEntity(message_id=incoming.id)) From ffe82497b3dab04b23aafb1253a097eb042d5a7a Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Thu, 23 Apr 2026 16:42:01 +0000 Subject: [PATCH 4/5] address comments --- examples/targeted-messages/src/main.py | 87 +++++-------------- .../api/activities/message/message.py | 20 +++++ .../entity/targeted_message_info_entity.py | 10 ++- .../apps/routing/activity_context.py | 34 +++++--- packages/apps/tests/test_activity_context.py | 11 +-- 5 files changed, 75 insertions(+), 87 deletions(-) diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py index d1fc38fb..4dd168d8 100644 --- a/examples/targeted-messages/src/main.py +++ b/examples/targeted-messages/src/main.py @@ -5,7 +5,7 @@ import asyncio -from microsoft_teams.api import Account, MessageActivity, MessageActivityInput, TargetedMessageInfoEntity +from microsoft_teams.api import MessageActivity, MessageActivityInput from microsoft_teams.api.activities.typing import TypingActivityInput from microsoft_teams.apps import ActivityContext, App @@ -27,39 +27,10 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): text = (ctx.activity.text or "").lower() - # ============================================ - # Test targeted SEND (create) - # ============================================ - if "test send" in text: - members = await ctx.api.conversations.members(ctx.activity.conversation.id).get_all() - - for member in members: - print(f"Member: {member.name} - {member.id}") - - 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) - 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 - ) - - result = await ctx.reply(targeted_reply) - print(f"Targeted REPLY result: {result}") - return - # ============================================ # Test targeted UPDATE # ============================================ - if "test update" in text: + if "update" in text: # First send a targeted message targeted_message = MessageActivityInput(text="🔒 [UPDATE] Original targeted message...").with_recipient( ctx.activity.from_, is_targeted=True @@ -91,7 +62,7 @@ async def update_after_delay(): # ============================================ # Test targeted DELETE # ============================================ - if "test delete" in text: + if "delete" in text: # First send a targeted message targeted_message = MessageActivityInput( text="🔒 [DELETE] This targeted message will be DELETED in 5 seconds..." @@ -119,48 +90,34 @@ async def delete_after_delay(): await ctx.reply( "**Targeted Messages Test Bot**\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" - "- `test prompt preview` - Reply with targeted message + prompt preview (reactive)\n" - "- `test proactive preview` - Reply with targeted message + prompt preview (proactive)\n\n" + "- `send` - Send a targeted message with prompt preview (auto-populated)\n" + "- `update` - Send a targeted message, then update it after 3 seconds\n" + "- `delete` - Send a targeted message, then delete it after 3 seconds\n" + "- `public` - Public message with prompt preview (visible to all)\n\n" "💡 *Test in a group chat to verify others don't see targeted messages!*" ) return # ============================================ - # Test Prompt Preview — Reactive flow - # The SDK auto-populates targetedMessageInfo - # when the incoming activity is a targeted message. - # ============================================ - if "test prompt preview" in text: - # Send a targeted reply — the SDK auto-attaches the - # targetedMessageInfo entity - targeted_reply = MessageActivityInput( - text="🔒 [PROMPT PREVIEW] Reactive — SDK auto-attaches targetedMessageInfo!" - ).with_recipient(ctx.activity.from_, is_targeted=True) - await ctx.send(targeted_reply) - return - - # ============================================ - # Test Prompt Preview — Proactive flow - # The developer manually attaches targetedMessageInfo - # using add_entity(). + # Test Prompt Preview — Public reply + # Everyone in the chat sees the reply with a + # collapsible preview of the original prompt. # ============================================ - if "test proactive preview" in text: - targeted_reply = ( - MessageActivityInput( - text="🔒 [PROMPT PREVIEW] Proactive — developer manually attaches targetedMessageInfo!" - ) - .with_recipient(ctx.activity.from_, is_targeted=True) - .add_entity(TargetedMessageInfoEntity(message_id=ctx.activity.id)) + if "public" in text: + await ctx.send( + MessageActivityInput(text="📋 Here is the public result — everyone can see this, with prompt preview!") ) - await ctx.send(targeted_reply) return - # Default - await ctx.reply('Say "help" for available commands.') + # Default — send a targeted reply. + # The SDK auto-populates targetedMessageInfo for prompt preview (reactive flow). + targeted_reply = MessageActivityInput( + text=( + f"🔒 You said: {ctx.activity.text or ''}\n\n" + "This is a **targeted message** with prompt preview — only YOU can see this!" + ) + ).with_recipient(ctx.activity.from_, is_targeted=True) + await ctx.send(targeted_reply) 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 7f125c95..5d28cf1a 100644 --- a/packages/api/src/microsoft_teams/api/activities/message/message.py +++ b/packages/api/src/microsoft_teams/api/activities/message/message.py @@ -6,6 +6,7 @@ from typing import Any, List, Literal, Optional, Self from microsoft_teams.cards import AdaptiveCard +from microsoft_teams.common.experimental import experimental from ...models import ( Account, @@ -32,6 +33,7 @@ Entity, Image, MessageEntity, + TargetedMessageInfoEntity, ) from ..utils import StripMentionsTextOptions, strip_mentions_text @@ -414,6 +416,24 @@ def add_feedback(self, mode: Literal["default", "custom"] = "default") -> Self: self.channel_data.feedback_loop_enabled = None 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). + + 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 [])) + 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/targeted_message_info_entity.py b/packages/api/src/microsoft_teams/api/models/entity/targeted_message_info_entity.py index f02f5d92..aa97e57e 100644 --- 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 @@ -5,11 +5,19 @@ 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.""" + """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" 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 05e19cad..879f2ec7 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, cast @@ -23,7 +24,6 @@ MessageActivityInput, SentActivity, SignOutUserParams, - TargetedMessageInfoEntity, TokenExchangeResource, TokenExchangeState, TokenPostResource, @@ -35,6 +35,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 ExperimentalWarning from microsoft_teams.common.http.client_token import Token from ..activity_sender import ActivitySender @@ -179,7 +180,7 @@ async def send( else: activity = message - self._auto_add_targeted_message_info(activity) + self._add_targeted_message_info_entity(activity) ref = conversation_ref or self.conversation_ref res = await self._activity_sender.send(activity, ref) @@ -191,9 +192,12 @@ async def reply(self, input: str | ActivityParams) -> SentActivity: In channels, sends to the current thread with a quoted reply. In other scopes, sends with a quoted reply. To send without quoting, use :meth:`send`. + + When the incoming activity is a targeted message, the blockquote is + skipped because prompt preview owns the preview surface. """ activity = MessageActivityInput(text=input) if isinstance(input, str) else input - if isinstance(activity, MessageActivityInput): + if isinstance(activity, MessageActivityInput) and not self._is_incoming_targeted(): 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 @@ -234,24 +238,30 @@ def _build_block_quote_for_activity(self) -> Optional[str]: ) return None - def _auto_add_targeted_message_info(self, activity: ActivityParams) -> None: + 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. """ - incoming = self.activity - if not (hasattr(incoming, "recipient") and hasattr(incoming.recipient, "is_targeted")): - return - if incoming.recipient.is_targeted is not True: + if not self._is_incoming_targeted(): return - if not isinstance(activity, MessageActivityInput): + if not isinstance(activity_params, MessageActivityInput): return - already_has = any(isinstance(e, TargetedMessageInfoEntity) for e in (activity.entities or [])) - if not already_has: - activity.add_entity(TargetedMessageInfoEntity(message_id=incoming.id)) + 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]: """ diff --git a/packages/apps/tests/test_activity_context.py b/packages/apps/tests/test_activity_context.py index eaac8b1e..5d9988a0 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.apps.routing.activity_context import ActivityContext @@ -413,8 +414,6 @@ def _make_non_targeted_activity(self) -> MagicMock: @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.""" - from microsoft_teams.api import TargetedMessageInfoEntity - activity = self._make_targeted_activity("1772129782775") ctx, mock_sender = _create_activity_context(activity=activity) @@ -442,8 +441,6 @@ async def test_send_does_not_add_entity_for_non_targeted(self) -> 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.""" - from microsoft_teams.api import TargetedMessageInfoEntity - activity = self._make_targeted_activity("1772129782775") ctx, mock_sender = _create_activity_context(activity=activity) @@ -458,8 +455,6 @@ async def test_send_does_not_duplicate_entity_if_already_present(self) -> None: @pytest.mark.asyncio async def test_reply_auto_adds_targeted_message_info_entity(self) -> None: """reply() also auto-adds targetedMessageInfo for targeted messages.""" - from microsoft_teams.api import TargetedMessageInfoEntity - activity = self._make_targeted_activity("1772129782775") ctx, mock_sender = _create_activity_context(activity=activity) @@ -474,8 +469,6 @@ async def test_reply_auto_adds_targeted_message_info_entity(self) -> None: @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.""" - from microsoft_teams.api.activities.typing import TypingActivityInput - activity = self._make_targeted_activity("1772129782775") ctx, mock_sender = _create_activity_context(activity=activity) From 2d024ddc812cb614da50f2bfd945e679bca33b4f Mon Sep 17 00:00:00 2001 From: Shanmathi Mayuram Krithivasan <37715033+ShanmathiMayuramKrithivasan@users.noreply.github.com> Date: Fri, 24 Apr 2026 10:24:18 +0000 Subject: [PATCH 5/5] update sample app --- examples/targeted-messages/src/main.py | 49 ++++++++++++-------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py index 4dd168d8..a2660cb7 100644 --- a/examples/targeted-messages/src/main.py +++ b/examples/targeted-messages/src/main.py @@ -84,40 +84,37 @@ async def delete_after_delay(): return # ============================================ - # Help / Default + # Test public reply + # Everyone in the chat sees the reply. # ============================================ - if "help" in text: - await ctx.reply( - "**Targeted Messages Test Bot**\n\n" - "**Commands:**\n" - "- `send` - Send a targeted message with prompt preview (auto-populated)\n" - "- `update` - Send a targeted message, then update it after 3 seconds\n" - "- `delete` - Send a targeted message, then delete it after 3 seconds\n" - "- `public` - Public message with prompt preview (visible to all)\n\n" - "💡 *Test in a group chat to verify others don't see targeted messages!*" + if "public" in text: + await ctx.send( + MessageActivityInput(text="📋 Here is the public result — everyone can see this!") ) return # ============================================ - # Test Prompt Preview — Public reply - # Everyone in the chat sees the reply with a - # collapsible preview of the original prompt. + # Test targeted SEND # ============================================ - if "public" in text: - await ctx.send( - MessageActivityInput(text="📋 Here is the public result — everyone can see this, with prompt preview!") - ) + if "send" in text: + targeted_reply = MessageActivityInput( + text="This is a **targeted message** — only YOU can see this!" + ).with_recipient(ctx.activity.from_, is_targeted=True) + await ctx.send(targeted_reply) return - # Default — send a targeted reply. - # The SDK auto-populates targetedMessageInfo for prompt preview (reactive flow). - targeted_reply = MessageActivityInput( - text=( - f"🔒 You said: {ctx.activity.text or ''}\n\n" - "This is a **targeted message** with prompt preview — only YOU can see this!" - ) - ).with_recipient(ctx.activity.from_, is_targeted=True) - await ctx.send(targeted_reply) + # ============================================ + # Help / Default + # ============================================ + await ctx.reply( + "**Targeted Messages Test Bot**\n\n" + "**Commands:**\n" + "- `send` - Send a targeted message\n" + "- `update` - Send a targeted message, then update it after 3 seconds\n" + "- `delete` - Send a targeted message, then delete it after 5 seconds\n" + "- `public` - Send a public message (visible to all)\n\n" + "💡 *Test in a group chat to verify others don't see targeted messages!*" + ) if __name__ == "__main__":