Skip to content

Commit 3ece4ba

Browse files
lilyydulilyduCopilot
authored
feat/bug: add custom feedback form & fix pydantic valid error on MessageUpdateActivity (#349)
resolves: #326 and #347 - added default empty string for `text` in `MessageUpdateActivity` (otherwise throws pydantic error) - added support for custom feedback form (through `ChannelData.feedback_loop`). **Custom Feedback Context:** [Learn Doc](https://learn.microsoft.com/en-us/microsoftteams/platform/bots/how-to/bot-messages-ai-generated-content?tabs=desktop%2Cjs%2Cbotmessage#feedback-buttons) - previously we had a flag called `enable_feedback_loop` which **only** enables the default feedback form - now, we have a param called `feedback_loop` that can either be default or custom. - however, the service doesn't accept both at once. Hence I normalized `enable_feedback_loop` into `feedback_loop`. whenever `enable_feedback_loop` is set to True, it'll set `feedback_loop` to default and itself back to `None`. <img width="659" height="426" alt="image" src="https://github.com/user-attachments/assets/a2a38486-e853-4fb1-b6c5-72da5264f00c" /> **Example Usage:** ``` @app.on_message async def on_message(ctx: ActivityContext[MessageActivity]): if ctx.activity.text == "feedback default": # This is the legacy approach that normalizes. Recommended to use `feedback_loop` instead. await ctx.send( MessageActivityInput(text="Rate this response!").with_channel_data(ChannelData(feedback_loop_enabled=True)) ) elif ctx.activity.text == "feedback custom": await ctx.send( MessageActivityInput(text="Rate this response!").add_feedback( mode="custom" ) # triggers message/fetchTask invoke ) @app.on_message_fetch_task async def on_feedback_fetch_task(ctx: ActivityContext[MessageFetchTaskInvokeActivity]): card = AdaptiveCard.model_validate( { "type": "AdaptiveCard", "version": "1.4", "body": [ {"type": "TextBlock", "text": "Tell us more about your feedback:"}, { "type": "Input.Text", "id": "feedbackText", "placeholder": "Enter your feedback here...", "isMultiline": True, }, ], "actions": [ {"type": "Action.Submit", "title": "Submit"}, ], } ) return TaskModuleInvokeResponse( task=TaskModuleContinueResponse( value=CardTaskModuleTaskInfo( title="Feedback", card=card_attachment(AdaptiveCardAttachment(content=card)), ) ) ) ``` --------- Co-authored-by: lilydu <lilydu+odspmdb@microsoft.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent a48a27b commit 3ece4ba

13 files changed

Lines changed: 332 additions & 15 deletions

File tree

packages/api/src/microsoft_teams/api/activities/invoke/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,13 @@
1414
from .execute_action import ExecuteActionInvokeActivity
1515
from .file_consent import FileConsentInvokeActivity
1616
from .handoff_action import HandoffActionInvokeActivity
17-
from .message import MessageSubmitActionInvokeActivity
17+
from .message import (
18+
MessageFetchTaskActionValue,
19+
MessageFetchTaskData,
20+
MessageFetchTaskInvokeActivity,
21+
MessageFetchTaskInvokeValue,
22+
MessageSubmitActionInvokeActivity,
23+
)
1824
from .message_extension import * # noqa: F403
1925
from .message_extension import MessageExtensionInvokeActivity
2026
from .sign_in import * # noqa: F403
@@ -32,6 +38,7 @@
3238
ConfigInvokeActivity,
3339
TabInvokeActivity,
3440
TaskInvokeActivity,
41+
MessageFetchTaskInvokeActivity,
3542
MessageSubmitActionInvokeActivity,
3643
HandoffActionInvokeActivity,
3744
SignInInvokeActivity,
@@ -48,6 +55,10 @@
4855
"ConfigInvokeActivity",
4956
"TabInvokeActivity",
5057
"TaskInvokeActivity",
58+
"MessageFetchTaskActionValue",
59+
"MessageFetchTaskData",
60+
"MessageFetchTaskInvokeActivity",
61+
"MessageFetchTaskInvokeValue",
5162
"MessageSubmitActionInvokeActivity",
5263
"HandoffActionInvokeActivity",
5364
"SignInInvokeActivity",

packages/api/src/microsoft_teams/api/activities/invoke/message/__init__.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@
33
Licensed under the MIT License.
44
"""
55

6+
from .fetch_task import (
7+
MessageFetchTaskActionValue,
8+
MessageFetchTaskData,
9+
MessageFetchTaskInvokeActivity,
10+
MessageFetchTaskInvokeValue,
11+
)
612
from .submit_action import MessageSubmitActionInvokeActivity
713

8-
__all__ = ["MessageSubmitActionInvokeActivity"]
14+
__all__ = [
15+
"MessageFetchTaskActionValue",
16+
"MessageFetchTaskData",
17+
"MessageFetchTaskInvokeActivity",
18+
"MessageFetchTaskInvokeValue",
19+
"MessageSubmitActionInvokeActivity",
20+
]
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""
2+
Copyright (c) Microsoft Corporation. All rights reserved.
3+
Licensed under the MIT License.
4+
"""
5+
6+
from typing import Literal
7+
8+
from ....models import CustomBaseModel
9+
from ...invoke_activity import InvokeActivity
10+
11+
12+
class MessageFetchTaskActionValue(CustomBaseModel):
13+
"""The nested action value containing the user's reaction."""
14+
15+
reaction: Literal["like", "dislike"]
16+
"""The feedback button the user clicked."""
17+
18+
19+
class MessageFetchTaskData(CustomBaseModel):
20+
"""The data payload nested inside the fetch task value."""
21+
22+
action_name: Literal["feedback"] = "feedback"
23+
"""The name of the action."""
24+
25+
action_value: MessageFetchTaskActionValue
26+
"""Contains the user's reaction."""
27+
28+
29+
class MessageFetchTaskInvokeValue(CustomBaseModel):
30+
"""
31+
Represents the value associated with a message fetch task.
32+
"""
33+
34+
data: MessageFetchTaskData
35+
"""The data payload containing action name and value."""
36+
37+
38+
class MessageFetchTaskInvokeActivity(InvokeActivity):
39+
"""
40+
Represents an activity sent when a message has a custom feedback loop
41+
and the user clicks a feedback button.
42+
The bot should respond with a task module (dialog) to collect feedback.
43+
"""
44+
45+
name: Literal["message/fetchTask"] = "message/fetchTask"
46+
"""The name of the operation associated with an invoke or event activity."""
47+
48+
value: MessageFetchTaskInvokeValue
49+
"""The value associated with the activity."""

packages/api/src/microsoft_teams/api/activities/message/message_update.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class _MessageUpdateBase(CustomBaseModel):
4949
class MessageUpdateActivity(_MessageUpdateBase, ActivityBase):
5050
"""Output model for received message update activities with required fields and read-only properties."""
5151

52-
text: str # pyright: ignore [reportGeneralTypeIssues]
52+
text: str = "" # pyright: ignore [reportGeneralTypeIssues, reportIncompatibleVariableOverride]
5353
"""The text content of the message."""
5454

5555
channel_data: MessageUpdateChannelData # pyright: ignore [reportGeneralTypeIssues]

packages/api/src/microsoft_teams/api/models/activity.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55

66
import warnings
77
from datetime import datetime
8-
from typing import Any, List, Optional, Self
8+
from typing import Any, List, Literal, Optional, Self
99

1010
from microsoft_teams.api.models.account import Account, ConversationAccount
11-
from microsoft_teams.api.models.channel_data.channel_data import ChannelData
11+
from microsoft_teams.api.models.channel_data.channel_data import ChannelData, FeedbackLoop
1212
from microsoft_teams.api.models.channel_data.channel_info import ChannelInfo
1313
from microsoft_teams.api.models.channel_data.notification_info import NotificationInfo
1414
from microsoft_teams.api.models.channel_data.team_info import TeamInfo
@@ -229,12 +229,19 @@ def add_ai_generated(self) -> Self:
229229

230230
return self
231231

232-
def add_feedback(self) -> Self:
233-
"""Enable message feedback."""
232+
def add_feedback(self, mode: Literal["default", "custom"] = "default") -> Self:
233+
"""
234+
Enable message feedback.
235+
236+
Args:
237+
mode: "default" shows Teams' built-in thumbs up/down UI.
238+
"custom" triggers a message/fetchTask invoke so the bot
239+
can return its own task module dialog.
240+
"""
234241
if not self.channel_data:
235-
self.channel_data = ChannelData(feedback_loop_enabled=True)
236-
else:
237-
self.channel_data.feedback_loop_enabled = True
242+
self.channel_data = ChannelData()
243+
self.channel_data.feedback_loop = FeedbackLoop(type=mode)
244+
self.channel_data.feedback_loop_enabled = None
238245
return self
239246

240247
def add_citation(self, position: int, appearance: CitationAppearance) -> Self:

packages/api/src/microsoft_teams/api/models/channel_data/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,18 @@
55

66
from .channel_data import ChannelData
77
from .channel_info import ChannelInfo
8+
from .feedback_loop import FeedbackLoop
89
from .notification_info import NotificationInfo
910
from .settings import ChannelDataSettings
1011
from .team_info import TeamInfo
1112
from .tenant_info import TenantInfo
1213

13-
__all__ = ["ChannelInfo", "NotificationInfo", "ChannelDataSettings", "TeamInfo", "TenantInfo", "ChannelData"]
14+
__all__ = [
15+
"ChannelInfo",
16+
"NotificationInfo",
17+
"ChannelDataSettings",
18+
"TeamInfo",
19+
"TenantInfo",
20+
"ChannelData",
21+
"FeedbackLoop",
22+
]

packages/api/src/microsoft_teams/api/models/channel_data/channel_data.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
Licensed under the MIT License.
44
"""
55

6-
from typing import Literal, Optional
6+
from typing import Literal, Optional, Self
7+
8+
from pydantic import model_validator
79

810
from ..custom_base_model import CustomBaseModel
911
from ..meetings import MeetingInfo
1012
from .channel_info import ChannelInfo
13+
from .feedback_loop import FeedbackLoop
1114
from .notification_info import NotificationInfo
1215
from .settings import ChannelDataSettings
1316
from .team_info import TeamInfo
@@ -41,7 +44,29 @@ class ChannelData(CustomBaseModel):
4144
"Information about the settings in which the message was sent."
4245

4346
feedback_loop_enabled: Optional[bool] = None
44-
"Whether or not the feedback loop feature is enabled."
47+
"""
48+
Legacy feedback loop flag. Setting this to True is equivalent to feedback_loop=FeedbackLoop(type="default").
49+
Recommended to use feedback_loop directly. This field is normalized on model creation.
50+
"""
51+
52+
feedback_loop: Optional[FeedbackLoop] = None
53+
"""
54+
Feedback loop configuration. Set type to 'custom' to show a task module dialog.
55+
Set to 'default' otherwise for standard feedback handling.
56+
"""
57+
58+
@model_validator(mode="after")
59+
def normalize_feedback(self) -> Self:
60+
"""
61+
Normalize the feedback loop configuration.
62+
This is necessary as the client only accepts either/or.
63+
"""
64+
if self.feedback_loop is not None:
65+
self.feedback_loop_enabled = None
66+
elif self.feedback_loop_enabled is True:
67+
self.feedback_loop = FeedbackLoop(type="default")
68+
self.feedback_loop_enabled = None
69+
return self
4570

4671
stream_id: Optional[str] = None
4772
"ID of the stream. Assigned after the initial update is sent."
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""
2+
Copyright (c) Microsoft Corporation. All rights reserved.
3+
Licensed under the MIT License.
4+
"""
5+
6+
from typing import Literal
7+
8+
from ..custom_base_model import CustomBaseModel
9+
10+
11+
class FeedbackLoop(CustomBaseModel):
12+
"""Configuration for a custom feedback loop on a message."""
13+
14+
type: Literal["custom", "default"] = "default"
15+
"""The type of feedback loop. Use `custom` to show a task module dialog"""

packages/api/tests/unit/test_activity.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,17 @@ def test_should_add_feedback_label(self, test_activity: ConcreteTestActivity) ->
124124
activity = test_activity.add_feedback()
125125

126126
assert activity.type == "test"
127-
assert activity.channel_data and activity.channel_data.feedback_loop_enabled is True
127+
assert activity.channel_data and activity.channel_data.feedback_loop is not None
128+
assert activity.channel_data.feedback_loop.type == "default"
129+
assert activity.channel_data.feedback_loop_enabled is None
130+
131+
def test_should_add_custom_feedback_label(self, test_activity: ConcreteTestActivity) -> None:
132+
activity = test_activity.add_feedback(mode="custom")
133+
134+
assert activity.type == "test"
135+
assert activity.channel_data and activity.channel_data.feedback_loop is not None
136+
assert activity.channel_data.feedback_loop.type == "custom"
137+
assert activity.channel_data.feedback_loop_enabled is None
128138

129139
def test_should_add_citation(self, test_activity: ConcreteTestActivity) -> None:
130140
activity = test_activity.add_citation(0, CitationAppearance(abstract="test", name="test"))

packages/api/tests/unit/test_message_activities.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from datetime import datetime
88
from typing import cast
99

10+
from microsoft_teams.api.activities import ActivityTypeAdapter
1011
from microsoft_teams.api.activities.message import (
1112
MessageActivity,
1213
MessageActivityInput,
@@ -259,7 +260,7 @@ def test_is_recipient_mentioned_false(self):
259260
activity.recipient = recipient
260261

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

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

515+
def test_message_update_activity_text_defaults_to_empty_string(self):
516+
"""Test that text field defaults to empty string when absent (e.g. attachment-only update)"""
517+
from_account = Account(id="bot-123", name="Test Bot")
518+
recipient = Account(id="user-456", name="Test User")
519+
conversation = ConversationAccount(id="conv-789", conversation_type="personal")
520+
channel_data = MessageUpdateChannelData(event_type="editMessage")
521+
522+
activity = MessageUpdateActivity(
523+
id="update-no-text",
524+
channel_data=channel_data,
525+
from_=from_account,
526+
conversation=conversation,
527+
recipient=recipient,
528+
)
529+
530+
assert activity.text == ""
531+
532+
def test_inbound_message_update_without_text_does_not_throw(self):
533+
"""Test that an inbound messageUpdate payload with no text (attachment-only) parses without error"""
534+
535+
payload = {
536+
"type": "messageUpdate",
537+
"id": "msg-123",
538+
"from": {"id": "user-123", "name": "Test User"},
539+
"conversation": {"id": "conv-456", "conversationType": "personal"},
540+
"recipient": {"id": "bot-789", "name": "Test Bot"},
541+
"channelData": {"eventType": "editMessage"},
542+
}
543+
544+
activity = ActivityTypeAdapter.validate_python(payload)
545+
546+
assert isinstance(activity, MessageUpdateActivity)
547+
assert activity.text == ""
548+
514549
def test_message_update_activity_optional_fields(self):
515550
"""Test message update activity with optional fields"""
516551
from_account = Account(id="bot-123", name="Test Bot")

0 commit comments

Comments
 (0)