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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,13 @@
from .execute_action import ExecuteActionInvokeActivity
from .file_consent import FileConsentInvokeActivity
from .handoff_action import HandoffActionInvokeActivity
from .message import MessageSubmitActionInvokeActivity
from .message import (
MessageFetchTaskActionValue,
MessageFetchTaskData,
MessageFetchTaskInvokeActivity,
MessageFetchTaskInvokeValue,
MessageSubmitActionInvokeActivity,
)
from .message_extension import * # noqa: F403
from .message_extension import MessageExtensionInvokeActivity
from .sign_in import * # noqa: F403
Expand All @@ -32,6 +38,7 @@
ConfigInvokeActivity,
TabInvokeActivity,
TaskInvokeActivity,
MessageFetchTaskInvokeActivity,
MessageSubmitActionInvokeActivity,
HandoffActionInvokeActivity,
SignInInvokeActivity,
Expand All @@ -48,6 +55,10 @@
"ConfigInvokeActivity",
"TabInvokeActivity",
"TaskInvokeActivity",
"MessageFetchTaskActionValue",
"MessageFetchTaskData",
"MessageFetchTaskInvokeActivity",
"MessageFetchTaskInvokeValue",
"MessageSubmitActionInvokeActivity",
"HandoffActionInvokeActivity",
"SignInInvokeActivity",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
Licensed under the MIT License.
"""

from .fetch_task import (
MessageFetchTaskActionValue,
MessageFetchTaskData,
MessageFetchTaskInvokeActivity,
MessageFetchTaskInvokeValue,
)
from .submit_action import MessageSubmitActionInvokeActivity

__all__ = ["MessageSubmitActionInvokeActivity"]
__all__ = [
"MessageFetchTaskActionValue",
"MessageFetchTaskData",
"MessageFetchTaskInvokeActivity",
"MessageFetchTaskInvokeValue",
"MessageSubmitActionInvokeActivity",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from typing import Literal

from ....models import CustomBaseModel
from ...invoke_activity import InvokeActivity


class MessageFetchTaskActionValue(CustomBaseModel):
"""The nested action value containing the user's reaction."""

reaction: Literal["like", "dislike"]
"""The feedback button the user clicked."""


class MessageFetchTaskData(CustomBaseModel):
"""The data payload nested inside the fetch task value."""

action_name: Literal["feedback"] = "feedback"
"""The name of the action."""

action_value: MessageFetchTaskActionValue
"""Contains the user's reaction."""


class MessageFetchTaskInvokeValue(CustomBaseModel):
"""
Represents the value associated with a message fetch task.
"""

data: MessageFetchTaskData
"""The data payload containing action name and value."""


class MessageFetchTaskInvokeActivity(InvokeActivity):
"""
Represents an activity sent when a message has a custom feedback loop
and the user clicks a feedback button.
The bot should respond with a task module (dialog) to collect feedback.
"""

name: Literal["message/fetchTask"] = "message/fetchTask"
"""The name of the operation associated with an invoke or event activity."""

value: MessageFetchTaskInvokeValue
"""The value associated with the activity."""
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class _MessageUpdateBase(CustomBaseModel):
class MessageUpdateActivity(_MessageUpdateBase, ActivityBase):
"""Output model for received message update activities with required fields and read-only properties."""

text: str # pyright: ignore [reportGeneralTypeIssues]
text: str = "" # pyright: ignore [reportGeneralTypeIssues, reportIncompatibleVariableOverride]
"""The text content of the message."""

channel_data: MessageUpdateChannelData # pyright: ignore [reportGeneralTypeIssues]
Expand Down
21 changes: 14 additions & 7 deletions packages/api/src/microsoft_teams/api/models/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@

import warnings
from datetime import datetime
from typing import Any, List, Optional, Self
from typing import Any, List, Literal, Optional, Self

from microsoft_teams.api.models.account import Account, ConversationAccount
from microsoft_teams.api.models.channel_data.channel_data import ChannelData
from microsoft_teams.api.models.channel_data.channel_data import ChannelData, FeedbackLoop
from microsoft_teams.api.models.channel_data.channel_info import ChannelInfo
from microsoft_teams.api.models.channel_data.notification_info import NotificationInfo
from microsoft_teams.api.models.channel_data.team_info import TeamInfo
Expand Down Expand Up @@ -229,12 +229,19 @@ def add_ai_generated(self) -> Self:

return self

def add_feedback(self) -> Self:
"""Enable message feedback."""
def add_feedback(self, mode: Literal["default", "custom"] = "default") -> Self:
"""
Enable message feedback.

Args:
mode: "default" shows Teams' built-in thumbs up/down UI.
"custom" triggers a message/fetchTask invoke so the bot
can return its own task module dialog.
"""
if not self.channel_data:
self.channel_data = ChannelData(feedback_loop_enabled=True)
else:
self.channel_data.feedback_loop_enabled = True
self.channel_data = ChannelData()
self.channel_data.feedback_loop = FeedbackLoop(type=mode)
self.channel_data.feedback_loop_enabled = None
return self

def add_citation(self, position: int, appearance: CitationAppearance) -> Self:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,18 @@

from .channel_data import ChannelData
from .channel_info import ChannelInfo
from .feedback_loop import FeedbackLoop
from .notification_info import NotificationInfo
from .settings import ChannelDataSettings
from .team_info import TeamInfo
from .tenant_info import TenantInfo

__all__ = ["ChannelInfo", "NotificationInfo", "ChannelDataSettings", "TeamInfo", "TenantInfo", "ChannelData"]
__all__ = [
"ChannelInfo",
"NotificationInfo",
"ChannelDataSettings",
"TeamInfo",
"TenantInfo",
"ChannelData",
"FeedbackLoop",
]
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
Licensed under the MIT License.
"""

from typing import Literal, Optional
from typing import Literal, Optional, Self

from pydantic import model_validator

from ..custom_base_model import CustomBaseModel
from ..meetings import MeetingInfo
from .channel_info import ChannelInfo
from .feedback_loop import FeedbackLoop
from .notification_info import NotificationInfo
from .settings import ChannelDataSettings
from .team_info import TeamInfo
Expand Down Expand Up @@ -41,7 +44,29 @@ class ChannelData(CustomBaseModel):
"Information about the settings in which the message was sent."

feedback_loop_enabled: Optional[bool] = None
"Whether or not the feedback loop feature is enabled."
"""
Legacy feedback loop flag. Setting this to True is equivalent to feedback_loop=FeedbackLoop(type="default").
Recommended to use feedback_loop directly. This field is normalized on model creation.
"""

feedback_loop: Optional[FeedbackLoop] = None
"""
Feedback loop configuration. Set type to 'custom' to show a task module dialog.
Set to 'default' otherwise for standard feedback handling.
"""

@model_validator(mode="after")
def normalize_feedback(self) -> Self:
"""
Normalize the feedback loop configuration.
This is necessary as the client only accepts either/or.
"""
if self.feedback_loop is not None:
self.feedback_loop_enabled = None
elif self.feedback_loop_enabled is True:
self.feedback_loop = FeedbackLoop(type="default")
self.feedback_loop_enabled = None
return self

stream_id: Optional[str] = None
"ID of the stream. Assigned after the initial update is sent."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from typing import Literal

from ..custom_base_model import CustomBaseModel


class FeedbackLoop(CustomBaseModel):
"""Configuration for a custom feedback loop on a message."""

type: Literal["custom", "default"] = "default"
"""The type of feedback loop. Use `custom` to show a task module dialog"""
12 changes: 11 additions & 1 deletion packages/api/tests/unit/test_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,17 @@ def test_should_add_feedback_label(self, test_activity: ConcreteTestActivity) ->
activity = test_activity.add_feedback()

assert activity.type == "test"
assert activity.channel_data and activity.channel_data.feedback_loop_enabled is True
assert activity.channel_data and activity.channel_data.feedback_loop is not None
assert activity.channel_data.feedback_loop.type == "default"
assert activity.channel_data.feedback_loop_enabled is None

def test_should_add_custom_feedback_label(self, test_activity: ConcreteTestActivity) -> None:
activity = test_activity.add_feedback(mode="custom")

assert activity.type == "test"
assert activity.channel_data and activity.channel_data.feedback_loop is not None
assert activity.channel_data.feedback_loop.type == "custom"
assert activity.channel_data.feedback_loop_enabled is None

def test_should_add_citation(self, test_activity: ConcreteTestActivity) -> None:
activity = test_activity.add_citation(0, CitationAppearance(abstract="test", name="test"))
Expand Down
37 changes: 36 additions & 1 deletion packages/api/tests/unit/test_message_activities.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from datetime import datetime
from typing import cast

from microsoft_teams.api.activities import ActivityTypeAdapter
from microsoft_teams.api.activities.message import (
MessageActivity,
MessageActivityInput,
Expand Down Expand Up @@ -259,7 +260,7 @@ def test_is_recipient_mentioned_false(self):
activity.recipient = recipient

# Mention someone else
other_account = Account(id="user-456", name="User", role="user")
other_account = Account(id="user-456", name="User")
mention = MentionEntity(mentioned=other_account, text="<at>User</at>")
activity.entities = [mention]

Expand Down Expand Up @@ -511,6 +512,40 @@ def test_message_update_activity_creation_undelete(self):
assert activity.type == "messageUpdate"
assert activity.channel_data.event_type == "undeleteMessage"

def test_message_update_activity_text_defaults_to_empty_string(self):
"""Test that text field defaults to empty string when absent (e.g. attachment-only update)"""
from_account = Account(id="bot-123", name="Test Bot")
recipient = Account(id="user-456", name="Test User")
conversation = ConversationAccount(id="conv-789", conversation_type="personal")
channel_data = MessageUpdateChannelData(event_type="editMessage")

activity = MessageUpdateActivity(
id="update-no-text",
channel_data=channel_data,
from_=from_account,
conversation=conversation,
recipient=recipient,
)

assert activity.text == ""

def test_inbound_message_update_without_text_does_not_throw(self):
"""Test that an inbound messageUpdate payload with no text (attachment-only) parses without error"""

payload = {
"type": "messageUpdate",
"id": "msg-123",
"from": {"id": "user-123", "name": "Test User"},
"conversation": {"id": "conv-456", "conversationType": "personal"},
"recipient": {"id": "bot-789", "name": "Test Bot"},
"channelData": {"eventType": "editMessage"},
}

activity = ActivityTypeAdapter.validate_python(payload)

assert isinstance(activity, MessageUpdateActivity)
assert activity.text == ""

def test_message_update_activity_optional_fields(self):
"""Test message update activity with optional fields"""
from_account = Account(id="bot-123", name="Test Bot")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
MessageExtensionSelectItemInvokeActivity,
MessageExtensionSettingInvokeActivity,
MessageExtensionSubmitActionInvokeActivity,
MessageFetchTaskInvokeActivity,
MessageReactionActivity,
MessageSubmitActionInvokeActivity,
MessageUpdateActivity,
Expand All @@ -49,6 +50,7 @@
TabFetchInvokeActivity,
TabInvokeResponse,
TabSubmitInvokeActivity,
TaskModuleInvokeResponse,
TraceActivity,
TypingActivity,
UninstalledActivity,
Expand Down Expand Up @@ -451,6 +453,16 @@ class ActivityConfig:
output_type_name="TabInvokeResponse",
is_invoke=True,
),
"message.fetch-task": ActivityConfig(
name="message.fetch-task",
method_name="on_message_fetch_task",
input_model=MessageFetchTaskInvokeActivity,
selector=lambda activity: activity.type == "invoke"
and cast(InvokeActivity, activity).name == "message/fetchTask",
output_model=TaskModuleInvokeResponse,
output_type_name="TaskModuleInvokeResponse",
is_invoke=True,
),
"message.submit": ActivityConfig(
name="message.submit",
method_name="on_message_submit",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
MessageExtensionSelectItemInvokeActivity,
MessageExtensionSettingInvokeActivity,
MessageExtensionSubmitActionInvokeActivity,
MessageFetchTaskInvokeActivity,
MessageReactionActivity,
MessageSubmitActionInvokeActivity,
MessageUpdateActivity,
Expand All @@ -62,6 +63,7 @@
MessagingExtensionActionInvokeResponse,
MessagingExtensionInvokeResponse,
TabInvokeResponse,
TaskModuleInvokeResponse,
TokenExchangeInvokeResponseType,
)

Expand Down Expand Up @@ -1251,6 +1253,38 @@ def decorator(
return decorator(handler)
return decorator

@overload
def on_message_fetch_task(
self, handler: InvokeHandler[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse]
) -> InvokeHandler[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse]: ...

@overload
def on_message_fetch_task(
self,
) -> Callable[
[InvokeHandler[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse]],
InvokeHandler[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse],
]: ...

def on_message_fetch_task(
self, handler: Optional[InvokeHandler[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse]] = None
) -> InvokeHandlerUnion[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse]:
"""Register a message.fetch-task activity handler."""

def decorator(
func: InvokeHandler[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse],
) -> InvokeHandler[MessageFetchTaskInvokeActivity, TaskModuleInvokeResponse]:
validate_handler_type(
func, MessageFetchTaskInvokeActivity, "on_message_fetch_task", "MessageFetchTaskInvokeActivity"
)
config = ACTIVITY_ROUTES["message.fetch-task"]
self.router.add_handler(config.selector, func)
return func

if handler is not None:
return decorator(handler)
return decorator

@overload
def on_message_submit(
self, handler: VoidInvokeHandler[MessageSubmitActionInvokeActivity]
Expand Down
Loading
Loading