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()