Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 30 additions & 44 deletions examples/targeted-messages/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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..."
Expand All @@ -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"
Comment on lines +112 to +115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test was removed

"💡 *Test in a group chat to verify others don't see targeted messages!*"
)


if __name__ == "__main__":
Expand Down
20 changes: 20 additions & 0 deletions packages/api/src/microsoft_teams/api/activities/message/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,6 +33,7 @@
Entity,
Image,
MessageEntity,
TargetedMessageInfoEntity,
)
from ..utils import StripMentionsTextOptions, strip_mentions_text

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -38,5 +39,6 @@
"SensitiveUsage",
"SensitiveUsagePattern",
"StreamInfoEntity",
"TargetedMessageInfoEntity",
"Entity",
]
2 changes: 2 additions & 0 deletions packages/api/src/microsoft_teams/api/models/entity/entity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,4 +24,5 @@
CitationEntity,
SensitiveUsageEntity,
ProductInfoEntity,
TargetedMessageInfoEntity,
]
Original file line number Diff line number Diff line change
@@ -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):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No preview marker on this class. For parity with Account.is_targeted (field-level .. warning:: Preview docstring at account.py:36-40) and the SDK's @experimental("ExperimentalTeamsTargeted") decorator at packages/common/src/microsoft_teams/common/experimental.py:23-76, one of these should be added since the surface depends on the still-preview targeted messages feature.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added experimental and preview markers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A parallel add_targeted_message_info(message_id: str) on MessageActivity would match the other add_* helpers and the TS addTargetedMessageInfo already in this PR. Gives a single home for the §1 P0 "one PP per message" dedup plus the usage docstring.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added on MessageActivityInput with one-PP-per-message dedup and experimental decorator; the reactive flow delegates to it.

"""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"
39 changes: 39 additions & 0 deletions packages/api/tests/unit/test_targeted_message_info_entity.py
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading