Skip to content
Merged
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
7 changes: 7 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@
- **Teams API Dependency**: Updated `microsoft-agents-hosting-msteams` to use `microsoft-teams-api` 2.1.0
- **Teams API Client Performance**: Reused a cached SSL context when constructing per-turn Teams API clients, reducing repeated CA certificate loading and client initialization overhead

## Developer Experience

- **Targeted Reply Prompt Previews**: Added automatic prompt-preview metadata when replying to inbound targeted activities, along with helpers for detecting targeted recipients and reading `TargetedMessageInfo`
- **Teams Quoted Replies**: Added `TeamsActivity.add_quoted_reply()` for creating quoted replies and `get_quoted_messages()` for reading quoted-message metadata, with typed `QuotedReply` and `QuotedReplyData` models
- **Targeted Activity Sending**: Improved `TeamsTurnContext.send_targeted_activity()` to accept text or an activity plus a recipient ID or `ChannelAccount`, preserve the intended recipient when applying conversation references, and send the Teams `isTargetedActivity=true` connector parameter

## Samples

- **Teams Conversation Agent Sample**: Expanded the sample and package documentation with targeted activity and quoted reply examples
- **Handling Attachments Sample**: Added a sample demonstrating incoming attachment downloads, inline and internet-hosted attachments, and Teams attachment uploads.

## Bug Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ class Activity(AgentsModel):
service_url: NonEmptyString = None
from_property: Annotated[ChannelAccount, Field(alias="from")] = None
conversation: ConversationAccount = None
recipient: ChannelAccount = None
recipient: ChannelAccount | None = None
text_format: NonEmptyString = None
attachment_layout: NonEmptyString = None
members_added: list[ChannelAccount] = None
Expand All @@ -176,7 +176,7 @@ class Activity(AgentsModel):
topic_name: NonEmptyString = None
history_disclosed: bool = None
locale: NonEmptyString = None
text: str = None
text: str | None = None
speak: str = None
input_hint: NonEmptyString = None
summary: NonEmptyString = None
Expand All @@ -199,7 +199,7 @@ class Activity(AgentsModel):
text_highlights: list[TextHighlight] = None
semantic_action: SemanticAction = None
caller_id: NonEmptyString = None
request_id: str | None = Field(None, exclude=True)
request_id: Annotated[str | None, Field(exclude=True)] = None

@field_validator("entities", mode="before")
@classmethod
Expand Down Expand Up @@ -290,7 +290,7 @@ def _serialize_sub_channel_data(

def apply_conversation_reference(
self, reference: ConversationReference, is_incoming: bool = False
):
) -> Self:
"""
Updates this activity with the delivery information from an existing ConversationReference.

Expand All @@ -305,24 +305,25 @@ def apply_conversation_reference(
outgoing activity with the correct delivery information.
"""
self.channel_id = reference.channel_id
self.locale = reference.locale or self.locale
self.service_url = reference.service_url
self.conversation = reference.conversation
self.request_id = reference.request_id

if reference.locale is not None:
self.locale = reference.locale

if is_incoming:
self.from_property = reference.user
self.recipient = reference.agent

if reference.activity_id is not None:
if reference.activity_id:
self.id = reference.activity_id
else:
self.from_property = reference.agent
self.recipient = reference.user

if reference.activity_id is not None:
# Targeted activities should have the recipient set to the intended user
# instead of the incoming activity's sender. This allows for proper routing
# of the outgoing activity to the user even if the incoming activity was sent
# to a different user (e.g. in group chat scenarios)
if not self.is_targeted_activity() or self.recipient is None:
self.recipient = reference.user
if reference.activity_id:
self.reply_to_id = reference.activity_id

return self
Expand Down Expand Up @@ -554,6 +555,58 @@ def with_suggested_actions(self, suggested_actions: SuggestedActions) -> Self:
self.suggested_actions = suggested_actions
return self

def with_recipient(self, recipient: str | ChannelAccount) -> Self:
"""
Sets the recipient of the activity.

:param recipient: The recipient of the activity. Can be an id or a ChannelAccount instance.
:returns: This activity, to allow for method chaining.
"""
if isinstance(recipient, str):
self.recipient = ChannelAccount(id=recipient, role=RoleTypes.user)
else:
self.recipient = recipient
return self

def with_targeted_recipient(self, recipient: str | ChannelAccount) -> Self:
"""
Make an activity targeted.

:param recipient: The recipient to target the activity to. Can be a string or a ChannelAccount instance.
:return: This activity, to allow for method chaining.
"""

if isinstance(recipient, str):
recipient = ChannelAccount(id=recipient, role=RoleTypes.user)

self.recipient = recipient
Comment thread
rodrigobr-msft marked this conversation as resolved.
self.entities = self.entities or []

found_targeted_entity: bool = False

if self.entities is not None:
# try to remove all targeted entities but keep the first one
def _keep(entity: Entity) -> bool:
nonlocal found_targeted_entity
entity_type = entity.type.lower()
if entity_type == EntityTypes.ACTIVITY_TREATMENT.value.lower():
treatment = getattr(entity, "treatment", None)
if treatment == ActivityTreatmentTypes.TARGETED:
if found_targeted_entity:
return False
found_targeted_entity = True
return True

# https://stackoverflow.com/questions/1207406/how-to-remove-items-from-a-list-while-iterating
# entity lists probably won't get too big, but heck
self.entities[:] = [entity for entity in self.entities if _keep(entity)]

if not found_targeted_entity:
self.entities.append(
ActivityTreatment(treatment=ActivityTreatmentTypes.TARGETED)
)
return self

def add_text(self, text: str) -> Self:
"""
Appends text to the existing text content of the activity.
Expand Down Expand Up @@ -673,7 +726,7 @@ def remove_mention_text(self, identifier: NonEmptyString | None) -> str:
appears in Activity.text.
"""
if not identifier:
return self.text
return self.text or ""

for mention in self.get_mentions():
if not mention.mentioned or mention.mentioned.id != identifier:
Expand All @@ -688,7 +741,7 @@ def remove_mention_text(self, identifier: NonEmptyString | None) -> str:
pattern, "", self.text or "", flags=re.IGNORECASE
).strip()

return self.text
return self.text or ""

def is_targeted_activity(self) -> bool:
"""
Expand All @@ -699,11 +752,13 @@ def is_targeted_activity(self) -> bool:
if not self.entities:
return False

target_type = EntityTypes.ACTIVITY_TREATMENT.lower()

for entity in self.entities:
if (
entity.type == EntityTypes.ACTIVITY_TREATMENT
and isinstance(entity, ActivityTreatment)
and entity.treatment == ActivityTreatmentTypes.TARGETED
entity.type.lower() == target_type
and getattr(entity, "treatment", None)
== ActivityTreatmentTypes.TARGETED
):
return True

Expand Down Expand Up @@ -1079,15 +1134,37 @@ def _convert_entity_list(
entities.append(Activity._convert_entity(e, entity_cls))
return entities

def get_product_info_entity(self) -> Optional[ProductInfo]:
def _get_entity_by_type(self, entity_type: str) -> Entity | None:
"""
Internal method to get the first entity of a specific type from the activity's entities.

:param entity_type: The type of the entity to retrieve. This will be converted to lowercase for comparison.
:return: The first entity of the specified type, or None if not found.
"""
if not self.entities:
return None
target = EntityTypes.PRODUCT_INFO.lower()
# validated entities can be Entity, and that prevents us from
# making assumptions about the casing of the 'type' attribute
raw_product_info = next(
filter(lambda e: e.type.lower() == target, self.entities), None
)
target = entity_type.lower()
return next((e for e in self.entities if e.type.lower() == target), None)

def _get_entities_by_type(self, entity_type: str) -> list[Entity]:
"""
Internal method to get all entities of a specific type from the activity's entities.

:param entity_type: The type of the entities to retrieve. This will be converted to lowercase for comparison.
:return: A list of entities of the specified type. Returns an empty list if none are found.
"""
if not self.entities:
return []
target = entity_type.lower()
return [e for e in self.entities if e.type.lower() == target]

def get_product_info_entity(self) -> Optional[ProductInfo]:
"""
Get the product info entity from the activity's entities.

:return: The product info entity, or None if not found.
"""
raw_product_info = self._get_entity_by_type(EntityTypes.PRODUCT_INFO)
if raw_product_info is None:
return None
return Activity._convert_entity(raw_product_info, ProductInfo)
Expand All @@ -1102,12 +1179,9 @@ def get_mentions(self) -> list[Mention]:
This method is defined on the :class:`microsoft_agents.activity.Activity` class, but is only intended for use with
a message activity, where the activity Activity.Type is set to ActivityTypes.Message.
"""
if not self.entities:
raw_mentions = self._get_entities_by_type(EntityTypes.MENTION.value)
if not raw_mentions:
return []
raw_mentions = [
x for x in self.entities if x.type.lower() == EntityTypes.MENTION.value
]

return Activity._convert_entity_list(raw_mentions, Mention)

def get_reply_conversation_reference(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ async def _continue_from_message(
self, activity: Activity
) -> tuple[TokenResponse, _FlowErrorTag]:
"""Handles the continuation of the flow from a message activity."""
magic_code: str = activity.text
magic_code: str = activity.text or ""
if magic_code and magic_code.isdigit() and len(magic_code) == 6:
token_response: TokenResponse = await self.get_user_token(magic_code)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,8 +308,16 @@ async def reply_to_activity(
body.type,
)

params = (
{"isTargetedActivity": "true"}
if ChannelId.get_channel(body.channel_id) == Channels.ms_teams
and body.is_targeted_activity()
else None
)
Comment thread
rodrigobr-msft marked this conversation as resolved.

async with self._wrapped_client().post(
url,
params=params,
json=body.model_dump(
by_alias=True, exclude_unset=True, exclude_none=True, mode="json"
),
Expand Down Expand Up @@ -369,8 +377,16 @@ async def send_to_conversation(
body.type,
)

params = (
{"isTargetedActivity": "true"}
if ChannelId.get_channel(body.channel_id) == Channels.ms_teams
and body.is_targeted_activity()
else None
)

async with self._wrapped_client().post(
url,
params=params,
json=body.model_dump(by_alias=True, exclude_unset=True, mode="json"),
) as response:
span.share(http_method="POST", status_code=response.status)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -358,25 +358,7 @@ def apply_conversation_reference(
:param is_incoming:
:return:
"""
activity.channel_id = reference.channel_id
if reference.locale:
activity.locale = reference.locale
activity.service_url = reference.service_url
activity.conversation = reference.conversation
activity.request_id = reference.request_id

if is_incoming:
activity.from_property = reference.user
activity.recipient = reference.agent
if reference.activity_id:
activity.id = reference.activity_id
else:
activity.from_property = reference.agent
activity.recipient = reference.user
if reference.activity_id:
activity.reply_to_id = reference.activity_id

return activity
return activity.apply_conversation_reference(reference, is_incoming)

@staticmethod
def get_reply_conversation_reference(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
from microsoft_agents.activity import (
Activity,
ActivityTypes,
Entity,
InvokeResponse,
)
from microsoft_agents.hosting.core import TurnContext

from .models import TargetedMessageInfo
from .type_defs import CommandSelector


Expand Down Expand Up @@ -146,3 +148,41 @@ async def _send_invoke_response(context: TurnContext, body: Any = None) -> None:
value=InvokeResponse(status=int(HTTPStatus.OK), body=serialized_body),
)
)


def _apply_prompt_preview_normalizer(activity: Activity, message_id: str) -> None:
"""Normalize the prompt preview for the given activity.

:param activity: The activity to normalize.
:param message_id: The message ID to associate with the targeted message info.
:return: None
"""
pattern = re.compile(r"<quoted messageId=\"[^\"]*\"/>")
target_type = "quotedreply"
if activity.entities:

def _keep(entity: Entity) -> bool:
return entity.type.lower() != target_type

activity.entities[:] = [entity for entity in activity.entities if _keep(entity)]

if activity.text:
text_without_placeholder = pattern.sub("", activity.text)
if len(text_without_placeholder) != len(activity.text):
activity.text = text_without_placeholder.strip()

if not activity._get_entities_by_type("targetedmessageinfo"):
activity.entities = activity.entities or []
activity.entities.append(TargetedMessageInfo(message_id=message_id))


def _is_recipient_targeted(activity: Activity) -> bool:
"""Check if the recipient is targeted in the given activity.

:param activity: The activity to check.
:return: True if the recipient is targeted, False otherwise.
"""
recipient = activity.recipient
if recipient is None:
return False
return recipient and getattr(recipient, "isTargeted", None) is True
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from .quoted_reply import QuotedReply, QuotedReplyData
from .targeted_message_info import TargetedMessageInfo

__all__ = ["QuotedReply", "QuotedReplyData", "TargetedMessageInfo"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from typing import Literal
from microsoft_agents.activity import AgentsModel, Entity


class QuotedReplyData(AgentsModel):
"""Represents a quoted reply in a Teams activity."""

message_id: str
sender_id: str | None = None
sender_name: str | None = None
preview: str | None = None
time: str | None = None
is_reply_deleted: bool | None = None
validated_message_reference: bool | None = None


class QuotedReply(Entity):
"""Contains the metadata for a quoted Teams message."""

type: Literal["quotedReply"] = "quotedReply"
quoted_reply: QuotedReplyData | None = None
Loading
Loading