diff --git a/Libraries/Microsoft.Teams.Api/Activities/Activity.cs b/Libraries/Microsoft.Teams.Api/Activities/Activity.cs
index 6c7dc4a6..ba709d54 100644
--- a/Libraries/Microsoft.Teams.Api/Activities/Activity.cs
+++ b/Libraries/Microsoft.Teams.Api/Activities/Activity.cs
@@ -269,9 +269,31 @@ public virtual Activity WithData(ChannelData value)
{
ChannelData ??= new();
ChannelData.Merge(value);
+ NormalizeFeedback();
return this;
}
+ ///
+ /// The Teams service rejects feedbackLoop and feedbackLoopEnabled
+ /// set at the same time. When is set it
+ /// wins; otherwise a legacy FeedbackLoopEnabled = true is upgraded to
+ /// .
+ ///
+ private void NormalizeFeedback()
+ {
+ if (ChannelData is null) return;
+
+ if (ChannelData.FeedbackLoop is not null)
+ {
+ ChannelData.FeedbackLoopEnabled = null;
+ }
+ else if (ChannelData.FeedbackLoopEnabled == true)
+ {
+ ChannelData.FeedbackLoop = new FeedbackLoop(FeedbackType.Default);
+ ChannelData.FeedbackLoopEnabled = null;
+ }
+ }
+
public virtual Activity WithData(string key, object? value)
{
ChannelData ??= new();
@@ -373,12 +395,39 @@ public virtual Activity AddSensitivityLabel(string name, string? description = n
}
///
- /// enable/disable message feedback
+ /// Legacy builder method of enabling default message feedback.
///
+ /// Whether to enable default message feedback.
public virtual Activity AddFeedback(bool value = true)
{
ChannelData ??= new();
- ChannelData.FeedbackLoopEnabled = value;
+
+ if (value)
+ {
+ ChannelData.FeedbackLoop = new FeedbackLoop(FeedbackType.Default);
+ }
+ else
+ {
+ ChannelData.FeedbackLoop = null;
+ }
+
+ ChannelData.FeedbackLoopEnabled = null;
+ return this;
+ }
+
+ ///
+ /// Enable message feedback with an explicit mode (default or custom).
+ ///
+ ///
+ /// shows Teams' built-in thumbs up/down UI.
+ /// triggers a message/fetchTask invoke
+ /// so the bot can return its own task module dialog.
+ ///
+ public virtual Activity AddFeedback(FeedbackType mode)
+ {
+ ChannelData ??= new();
+ ChannelData.FeedbackLoop = new FeedbackLoop(mode);
+ ChannelData.FeedbackLoopEnabled = null;
return this;
}
diff --git a/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs b/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs
index 4135347f..05e4dab5 100644
--- a/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs
+++ b/Libraries/Microsoft.Teams.Api/Activities/Invokes/MessageActivity.cs
@@ -20,10 +20,12 @@ public partial class Name : StringEnum
public abstract class MessageActivity(Name.Messages name) : InvokeActivity(new(name.Value))
{
public Messages.SubmitActionActivity ToSubmitAction() => (Messages.SubmitActionActivity)this;
+ public Messages.FetchTaskActivity ToFetchTask() => (Messages.FetchTaskActivity)this;
public override object ToType(Type type, IFormatProvider? provider)
{
if (type == typeof(Messages.SubmitActionActivity)) return ToSubmitAction();
+ if (type == typeof(Messages.FetchTaskActivity)) return ToFetchTask();
return this;
}
@@ -53,6 +55,7 @@ public override bool CanConvert(Type typeToConvert)
return name switch
{
"message/submitAction" => JsonSerializer.Deserialize(element.ToString(), options),
+ "message/fetchTask" => JsonSerializer.Deserialize(element.ToString(), options),
_ => throw new JsonException($"failed to deserialize message activity '{name}' doesn't match any known types.")
};
}
@@ -65,6 +68,12 @@ public override void Write(Utf8JsonWriter writer, MessageActivity value, JsonSer
return;
}
+ if (value is Messages.FetchTaskActivity fetchTask)
+ {
+ JsonSerializer.Serialize(writer, fetchTask, options);
+ return;
+ }
+
JsonSerializer.Serialize(writer, value, options);
}
}
diff --git a/Libraries/Microsoft.Teams.Api/Activities/Invokes/Messages/FetchTaskActivity.cs b/Libraries/Microsoft.Teams.Api/Activities/Invokes/Messages/FetchTaskActivity.cs
new file mode 100644
index 00000000..267c3d8d
--- /dev/null
+++ b/Libraries/Microsoft.Teams.Api/Activities/Invokes/Messages/FetchTaskActivity.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+using Microsoft.Teams.Common;
+
+namespace Microsoft.Teams.Api.Activities.Invokes;
+
+public partial class Name : StringEnum
+{
+ public partial class Messages : StringEnum
+ {
+ public static readonly Messages FetchTask = new("message/fetchTask");
+ public bool IsFetchTask => FetchTask.Equals(Value);
+ }
+}
+
+///
+/// The feedback button the user clicked.
+///
+[JsonConverter(typeof(JsonConverter))]
+public partial class Reaction(string value) : StringEnum(value)
+{
+ public static readonly Reaction Like = new("like");
+ public bool IsLike => Like.Equals(Value);
+
+ public static readonly Reaction Dislike = new("dislike");
+ public bool IsDislike => Dislike.Equals(Value);
+}
+
+public static partial class Messages
+{
+ ///
+ /// 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.
+ ///
+ public class FetchTaskActivity() : MessageActivity(Name.Messages.FetchTask)
+ {
+ ///
+ /// A value that is associated with the activity.
+ ///
+ [JsonPropertyName("value")]
+ [JsonPropertyOrder(32)]
+ public new required FetchTaskValue Value
+ {
+ get => (FetchTaskValue)base.Value!;
+ set => base.Value = value;
+ }
+
+ ///
+ /// The value associated with a message fetch task.
+ ///
+ public class FetchTaskValue
+ {
+ ///
+ /// The data payload containing action name and value.
+ ///
+ [JsonPropertyName("data")]
+ [JsonPropertyOrder(0)]
+ public required FetchTaskData Data { get; set; }
+ }
+
+ ///
+ /// The data payload nested inside the fetch task value.
+ ///
+ public class FetchTaskData
+ {
+ ///
+ /// The name of the action.
+ ///
+ [JsonPropertyName("actionName")]
+ [JsonPropertyOrder(0)]
+ public string ActionName { get; set; } = "feedback";
+
+ ///
+ /// Contains the user's reaction.
+ ///
+ [JsonPropertyName("actionValue")]
+ [JsonPropertyOrder(1)]
+ public required FetchTaskActionValue ActionValue { get; set; }
+ }
+
+ ///
+ /// The nested action value containing the user's reaction.
+ ///
+ public class FetchTaskActionValue
+ {
+ ///
+ /// The feedback button the user clicked.
+ ///
+ [JsonPropertyName("reaction")]
+ [JsonPropertyOrder(0)]
+ public required Reaction Reaction { get; set; }
+ }
+ }
+}
diff --git a/Libraries/Microsoft.Teams.Api/ChannelData.cs b/Libraries/Microsoft.Teams.Api/ChannelData.cs
index ad3712db..43640f63 100644
--- a/Libraries/Microsoft.Teams.Api/ChannelData.cs
+++ b/Libraries/Microsoft.Teams.Api/ChannelData.cs
@@ -63,7 +63,10 @@ public class ChannelData
public App? App { get; set; }
///
- /// Whether or not the feedback loop feature is enabled
+ /// Legacy feedback loop flag. Setting this to true is equivalent to
+ /// FeedbackLoop = new FeedbackLoop(FeedbackType.Default).
+ /// Prefer setting directly; this field is normalized
+ /// by .
///
[JsonPropertyName("feedbackLoopEnabled")]
[JsonPropertyOrder(7)]
@@ -109,6 +112,17 @@ public class ChannelData
[JsonPropertyOrder(14)]
public MembershipSource? MembershipSource { get; set; }
+ ///
+ /// Feedback loop configuration.
+ /// Set Type to to trigger a
+ /// message/fetchTask invoke for a bot-provided task module dialog.
+ /// Set Type to for the standard
+ /// Teams thumbs up/down UI.
+ ///
+ [JsonPropertyName("feedbackLoop")]
+ [JsonPropertyOrder(15)]
+ public FeedbackLoop? FeedbackLoop { get; set; }
+
///
/// All extra data present
///
diff --git a/Libraries/Microsoft.Teams.Api/FeedbackLoop.cs b/Libraries/Microsoft.Teams.Api/FeedbackLoop.cs
new file mode 100644
index 00000000..294e1fb7
--- /dev/null
+++ b/Libraries/Microsoft.Teams.Api/FeedbackLoop.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+using Microsoft.Teams.Common;
+
+namespace Microsoft.Teams.Api;
+
+///
+/// The type of feedback loop.
+/// Use Custom to trigger a message/fetchTask invoke so the bot
+/// can return its own task module dialog.
+/// Use Default for the standard Teams thumbs up/down UI.
+///
+[JsonConverter(typeof(JsonConverter))]
+public partial class FeedbackType(string value) : StringEnum(value)
+{
+ public static readonly FeedbackType Default = new("default");
+ public bool IsDefault => Default.Equals(Value);
+
+ public static readonly FeedbackType Custom = new("custom");
+ public bool IsCustom => Custom.Equals(Value);
+}
+
+///
+/// Configuration for a feedback loop on a message.
+///
+public class FeedbackLoop
+{
+ ///
+ /// The type of feedback loop.
+ ///
+ [JsonPropertyName("type")]
+ [JsonPropertyOrder(0)]
+ public FeedbackType Type { get; set; } = FeedbackType.Default;
+
+ public FeedbackLoop() { }
+
+ public FeedbackLoop(FeedbackType type)
+ {
+ Type = type;
+ }
+}
diff --git a/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FetchTaskActivity.cs b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FetchTaskActivity.cs
new file mode 100644
index 00000000..f2fa35ea
--- /dev/null
+++ b/Libraries/Microsoft.Teams.Apps/Activities/Invokes/Messages/FetchTaskActivity.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Teams.Api.Activities;
+using Microsoft.Teams.Api.Activities.Invokes;
+using Microsoft.Teams.Apps.Routing;
+
+namespace Microsoft.Teams.Apps.Activities.Invokes;
+
+public static partial class Message
+{
+ [AttributeUsage(AttributeTargets.Method, Inherited = true)]
+ public class FetchTaskAttribute() : InvokeAttribute(Api.Activities.Invokes.Name.Messages.FetchTask, typeof(Messages.FetchTaskActivity))
+ {
+ public override object Coerce(IContext context) => context.ToActivityType();
+ }
+}
+
+public static partial class AppInvokeActivityExtensions
+{
+ ///
+ /// Registers a handler for message/fetchTask activities.
+ /// The bot should return a task module response containing the dialog to show the user.
+ ///
+ public static App OnMessageFetchTask(this App app, Func, CancellationToken, Task> handler)
+ {
+ app.Router.Register(new Route()
+ {
+ Name = string.Join("/", [ActivityType.Invoke, Name.Messages.FetchTask]),
+ Type = app.Status is null ? RouteType.System : RouteType.User,
+ Handler = async context => await handler(context.ToActivityType(), context.CancellationToken),
+ Selector = activity => activity is Messages.FetchTaskActivity
+ });
+
+ return app;
+ }
+}
diff --git a/Samples/Samples.AI/Handlers/FeedbackHandler.cs b/Samples/Samples.AI/Handlers/FeedbackHandler.cs
index 572c1ffb..8e41c5b7 100644
--- a/Samples/Samples.AI/Handlers/FeedbackHandler.cs
+++ b/Samples/Samples.AI/Handlers/FeedbackHandler.cs
@@ -3,8 +3,12 @@
using Microsoft.Teams.AI.Models.OpenAI;
using Microsoft.Teams.AI.Prompts;
using Microsoft.Teams.AI.Templates;
+using Microsoft.Teams.Api;
using Microsoft.Teams.Api.Activities.Invokes;
using Microsoft.Teams.Apps;
+using Microsoft.Teams.Cards;
+
+using TaskModules = Microsoft.Teams.Api.TaskModules;
namespace Samples.AI.Handlers;
@@ -38,13 +42,15 @@ public static async Task HandleFeedbackLoop(OpenAIChatModel model, IContext
+ /// Builds the task module (dialog) shown when the user clicks a feedback
+ /// button on a message whose feedback loop type is custom.
+ ///
+ public static TaskModules.Response HandleFeedbackFetchTask(IContext context)
+ {
+ var reaction = context.Activity.Value.Data.ActionValue.Reaction;
+ context.Log.Info($"[HANDLER] Feedback fetch-task invoked, reaction: {reaction}");
+
+ var card = new AdaptiveCard
+ {
+ Schema = "http://adaptivecards.io/schemas/adaptive-card.json",
+ Body = new List
+ {
+ new TextBlock($"You reacted {reaction}. Tell us more (optional):") { Wrap = true },
+ new TextInput
+ {
+ Id = "feedbackText",
+ Placeholder = "Your feedback...",
+ IsMultiline = true,
+ }
+ },
+ Actions = new List
+ {
+ new SubmitAction { Title = "Submit" }
+ }
+ };
+
+ return new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo
+ {
+ Title = "Feedback",
+ Card = new Attachment(card),
+ }));
+ }
+
///
/// Handles feedback submissions from users
///
diff --git a/Samples/Samples.AI/Program.cs b/Samples/Samples.AI/Program.cs
index 868cadd4..6134fb1e 100644
--- a/Samples/Samples.AI/Program.cs
+++ b/Samples/Samples.AI/Program.cs
@@ -144,7 +144,16 @@
}
});
-// Feedback submission handler
+// Custom feedback fetch-task handler.
+// Teams fires this when the user clicks a feedback button on a message whose
+// feedback loop type is 'custom' — the bot must return a task module dialog.
+teamsApp.OnMessageFetchTask((context, cancellationToken) =>
+{
+ context.Log.Info($"[HANDLER] Feedback fetch-task received");
+ return Task.FromResult(FeedbackHandler.HandleFeedbackFetchTask(context));
+});
+
+// Feedback submission handler (fires after the user submits the dialog above).
teamsApp.OnFeedback((context, cancellationToken) =>
{
context.Log.Info($"[HANDLER] Feedback submission received");
diff --git a/Samples/Samples.AI/Samples.AI.csproj b/Samples/Samples.AI/Samples.AI.csproj
index a22dfe9c..a2dab774 100644
--- a/Samples/Samples.AI/Samples.AI.csproj
+++ b/Samples/Samples.AI/Samples.AI.csproj
@@ -9,6 +9,7 @@
+
diff --git a/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/Messages/FetchTaskActivityTests.cs b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/Messages/FetchTaskActivityTests.cs
new file mode 100644
index 00000000..8ecfa1ee
--- /dev/null
+++ b/Tests/Microsoft.Teams.Apps.Tests/Activities/Invokes/Messages/FetchTaskActivityTests.cs
@@ -0,0 +1,142 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.Teams.Api.Activities;
+using Microsoft.Teams.Api.Activities.Invokes;
+using Microsoft.Teams.Api.Auth;
+using Microsoft.Teams.Apps.Activities.Invokes;
+using Microsoft.Teams.Apps.Annotations;
+using Microsoft.Teams.Apps.Testing.Plugins;
+
+using TaskModules = Microsoft.Teams.Api.TaskModules;
+
+namespace Microsoft.Teams.Apps.Tests.Activities;
+
+public class FetchTaskActivityTests
+{
+ private readonly App _app = new();
+ private readonly IToken _token = Globals.Token;
+ private readonly Controller _controller = new();
+
+ public FetchTaskActivityTests()
+ {
+ _app.AddPlugin(new TestPlugin());
+ _app.AddController(_controller);
+ }
+
+ private static Messages.FetchTaskActivity SetupFetchTaskActivity(string reaction = "like")
+ {
+ return new Messages.FetchTaskActivity
+ {
+ Value = new Messages.FetchTaskActivity.FetchTaskValue
+ {
+ Data = new Messages.FetchTaskActivity.FetchTaskData
+ {
+ ActionValue = new Messages.FetchTaskActivity.FetchTaskActionValue
+ {
+ Reaction = new Reaction(reaction),
+ },
+ },
+ },
+ };
+ }
+
+ [Fact]
+ public async Task Should_CallHandler()
+ {
+ var calls = 0;
+
+ _app.OnMessageFetchTask((context, ct) =>
+ {
+ calls++;
+ Assert.True(context.Activity.Type.IsInvoke);
+ Assert.True(context.Activity.Name == Name.Messages.FetchTask);
+ Assert.True(context.Activity.Value.Data.ActionValue.Reaction.IsLike);
+ return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo { Title = "Feedback" })));
+ });
+
+ var res = await _app.Process(_token, SetupFetchTaskActivity());
+
+ Assert.Equal(System.Net.HttpStatusCode.OK, res.Status);
+ Assert.Equal(1, calls);
+ Assert.Equal(1, _controller.Calls);
+ Assert.Equal(2, res.Meta.Routes);
+ }
+
+ [Fact]
+ public async Task Should_Not_CallHandler_OnOtherActivity()
+ {
+ var calls = 0;
+
+ _app.OnMessageFetchTask((context, ct) =>
+ {
+ calls++;
+ return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo())));
+ });
+
+ var res = await _app.Process(_token, new TypingActivity());
+
+ Assert.Equal(System.Net.HttpStatusCode.OK, res.Status);
+ Assert.Equal(0, calls);
+ Assert.Equal(0, _controller.Calls);
+ Assert.Equal(0, res.Meta.Routes);
+ }
+
+ [Fact]
+ public async Task Should_Not_CallHandler_OnSubmitAction()
+ {
+ var calls = 0;
+
+ _app.OnMessageFetchTask((context, ct) =>
+ {
+ calls++;
+ return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo())));
+ });
+
+ var submit = new Messages.SubmitActionActivity
+ {
+ Value = new Messages.SubmitActionActivity.SubmitActionValue
+ {
+ ActionName = "feedback",
+ ActionValue = "test",
+ },
+ };
+
+ var res = await _app.Process(_token, submit);
+
+ Assert.Equal(System.Net.HttpStatusCode.OK, res.Status);
+ Assert.Equal(0, calls);
+ }
+
+ [Fact]
+ public async Task Should_CallHandler_OnDislikeReaction()
+ {
+ var calls = 0;
+
+ _app.OnMessageFetchTask((context, ct) =>
+ {
+ calls++;
+ Assert.True(context.Activity.Value.Data.ActionValue.Reaction.IsDislike);
+ return Task.FromResult(new TaskModules.Response(new TaskModules.ContinueTask(new TaskModules.TaskInfo())));
+ });
+
+ var res = await _app.Process(_token, SetupFetchTaskActivity("dislike"));
+
+ Assert.Equal(System.Net.HttpStatusCode.OK, res.Status);
+ Assert.Equal(1, calls);
+ }
+
+ [TeamsController]
+ public class Controller
+ {
+ public int Calls { get; private set; }
+
+ [Microsoft.Teams.Apps.Activities.Invokes.Message.FetchTask]
+ public void OnFetchTask([Context] IContext.Next next)
+ {
+ Calls++;
+ next();
+ }
+ }
+}
+