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(); + } + } +} +