diff --git a/examples/targeted-messages/src/main.py b/examples/targeted-messages/src/main.py index f2e3de26..a2660cb7 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 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..." @@ -113,22 +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" - "- `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!*" + if "public" in text: + await ctx.send( + MessageActivityInput(text="📋 Here is the public result — everyone can see this!") ) return - # Default - await ctx.reply('Say "help" for available commands.') + # ============================================ + # Test targeted SEND + # ============================================ + 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 + + # ============================================ + # 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__": 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/__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..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..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..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 @@ -34,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 @@ -178,6 +180,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 @@ -188,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 @@ -231,6 +238,31 @@ def _build_block_quote_for_activity(self) -> Optional[str]: ) return 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. + """ + 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 4e4fa429..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 @@ -387,3 +388,91 @@ 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.""" + 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" + + @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