diff --git a/core/samples/TeamsBot/Program.cs b/core/samples/TeamsBot/Program.cs index 37a4a368..ec7ac106 100644 --- a/core/samples/TeamsBot/Program.cs +++ b/core/samples/TeamsBot/Program.cs @@ -7,6 +7,7 @@ using Microsoft.Teams.Bot.Apps; using Microsoft.Teams.Bot.Apps.Handlers; using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; using TeamsBot; WebApplicationBuilder webAppBuilder = WebApplication.CreateSlimBuilder(args); @@ -27,6 +28,22 @@ await context.SendActivityAsync( { TextFormat = TextFormats.Markdown }, cancellationToken); + + + var helpActivity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText(WelcomeMessageMiddleware.WelcomeMessage, TextFormats.Markdown) + .WithSuggestedActions(new SuggestedActions() + { + To = [context.Activity.From?.Id!], + Actions = [ + new SuggestedAction(ActionType.IMBack, "hello") { Value = "hello" }, + new SuggestedAction(ActionType.IMBack, "feedback") { Value = "feedback" }, + ] + }) + .Build(); + + await context.SendActivityAsync(helpActivity, cancellationToken); }); // Pattern-based handler: matches "hello" (case-insensitive) @@ -233,6 +250,27 @@ await context.TeamsBotApplication.Api.Conversations.Reactions.DeleteAsync( await context.SendActivityAsync(taskActivity, cancellationToken); }); +teamsApp.OnMessage("(?i)^suggested$", async (context, cancellationToken) => +{ + var suggestedActions = new SuggestedActions() + { + To = [context.Activity.From?.Id!], + Actions = [ + new SuggestedAction(ActionType.IMBack, "Option 1") { Value = "You chose option 1" }, + new SuggestedAction(ActionType.IMBack, "Option 2") { Value = "You chose option 2" }, + new SuggestedAction(ActionType.IMBack, "Option 3") { Value = "You chose option 3" } + ] + }; + + var reply = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Here are some suggested actions for you:") + .WithSuggestedActions(suggestedActions) + .Build(); + + await context.SendActivityAsync(reply, cancellationToken); +}); + // Regex-based handler: matches commands starting with "/" Regex commandRegex = Regexes.CommandRegex(); teamsApp.OnMessage(commandRegex, async (context, cancellationToken) => @@ -254,24 +292,6 @@ await context.TeamsBotApplication.Api.Conversations.Reactions.DeleteAsync( } }); -//// Catch-all message handler: echoes the message back with a mention -//teamsApp.OnMessage(async (context, cancellationToken) => -//{ -// await context.SendTypingActivityAsync(cancellationToken); - -// ArgumentNullException.ThrowIfNull(context.Activity.From); - -// string replyText = $"You sent: `{context.Activity.Text}`. Type `help` to see available commands."; - -// TeamsActivity ta = TeamsActivity.CreateBuilder() -// .WithType(TeamsActivityType.Message) -// .WithText(replyText) -// .AddMention(context.Activity.From) -// .Build(); - -// await context.SendActivityAsync(ta, cancellationToken); -//}); - // ==================== MESSAGE LIFECYCLE ==================== teamsApp.OnMessageUpdate(async (context, cancellationToken) => diff --git a/core/samples/TeamsBot/WelcomeMessageMiddleware.cs b/core/samples/TeamsBot/WelcomeMessageMiddleware.cs index 62b1dfaf..20e6cc1e 100644 --- a/core/samples/TeamsBot/WelcomeMessageMiddleware.cs +++ b/core/samples/TeamsBot/WelcomeMessageMiddleware.cs @@ -23,6 +23,7 @@ internal class WelcomeMessageMiddleware : ITurnMiddleware - `card` - Send an Adaptive Card with a feedback form - `feedback` - Feedback form with Adaptive Card action round-trip - `task` - Open a task module dialog +- `suggested` - Suggested actions ** Commands** - `/help` - Available slash commands diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageActivity.cs index e4ba52fd..7ba071aa 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/Activities/MessageActivity.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.Teams.Bot.Core.Schema; @@ -71,6 +72,18 @@ protected MessageActivity(CoreActivity activity) : base(activity) AttachmentLayout = attachmentLayout?.ToString(); activity.Properties.Remove("attachmentLayout"); } + if (activity.Properties.TryGetValue("suggestedActions", out object? suggestedActions) && suggestedActions != null) + { + if (suggestedActions is JsonElement je) + { + SuggestedActions = JsonSerializer.Deserialize(je.GetRawText()); + } + else + { + SuggestedActions = suggestedActions as SuggestedActions; + } + activity.Properties.Remove("suggestedActions"); + } /* if (activity.Properties.TryGetValue("speak", out var speak)) { @@ -122,6 +135,8 @@ protected MessageActivity(CoreActivity activity) : base(activity) [JsonPropertyName("attachmentLayout")] public string? AttachmentLayout { get; set; } + + //TODO : Review properties /* /// @@ -159,9 +174,6 @@ protected MessageActivity(CoreActivity activity) : base(activity) /// [JsonPropertyName("expiration")] public string? Expiration { get; set; } - - [JsonPropertyName("suggestedActions")] - public SuggestedActions? SuggestedActions { get; set; } */ } @@ -263,19 +275,4 @@ public static class DeliveryModes } -public class SuggestedActions -{ - /// - /// Ids of the recipients that the actions should be shown to. These Ids are relative to the - /// channelId and a subset of all recipients of the activity - /// - [JsonPropertyName("to")] - public IList To { get; set; } = []; - - /// - /// Actions that can be shown to the user - /// - [JsonPropertyName("actions")] - public IList Actions { get; set; } = []; -} */ diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedAction.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedAction.cs new file mode 100644 index 00000000..d85b19a5 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedAction.cs @@ -0,0 +1,131 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents a clickable action +/// +public class SuggestedAction +{ + /// + /// Default constructor for JSON deserialization. + /// + public SuggestedAction() + { + } + + /// + /// Initializes a new instance of the class with the specified type and title. + /// + /// The type of action. See for common values. + /// The text description displayed on the button. + public SuggestedAction(string type, string title) + { + Type = type; + Title = title; + } + + /// + /// Gets or sets the type of action implemented by this button. + /// See for common values. + /// + [JsonPropertyName("type")] + public string? Type { get; set; } + + /// + /// Gets or sets the text description which appears on the button. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// Gets or sets the image URL which will appear on the button, next to the text label. + /// + [JsonPropertyName("image")] + public string? Image { get; set; } + + /// + /// Gets or sets the text for this action. + /// + [JsonPropertyName("text")] + public string? Text { get; set; } + + /// + /// Gets or sets the text to display in the chat feed if the button is clicked. + /// + [JsonPropertyName("displayText")] + public string? DisplayText { get; set; } + + /// + /// Gets or sets the supplementary parameter for the action. + /// The content of this property depends on the action type. + /// + [JsonPropertyName("value")] + public object? Value { get; set; } + + /// + /// Gets or sets the channel-specific data associated with this action. + /// + [JsonPropertyName("channelData")] + public object? ChannelData { get; set; } + + /// + /// Gets or sets the alternate image text to be used in place of the image. + /// + [JsonPropertyName("imageAltText")] + public string? ImageAltText { get; set; } +} + +/// +/// String constants for card action types. +/// +public static class ActionType +{ + /// + /// Opens the specified URL in the browser. + /// + public const string OpenUrl = "openUrl"; + + /// + /// Sends a message back to the bot as if the user typed it (visible to all conversation members). + /// + public const string IMBack = "imBack"; + + /// + /// Sends a message back to the bot privately (not visible to other conversation members). + /// + public const string PostBack = "postBack"; + + /// + /// Plays the specified audio content. + /// + public const string PlayAudio = "playAudio"; + + /// + /// Plays the specified video content. + /// + public const string PlayVideo = "playVideo"; + + /// + /// Displays the specified image. + /// + public const string ShowImage = "showImage"; + + /// + /// Downloads the specified file. + /// + public const string DownloadFile = "downloadFile"; + + /// + /// Initiates a sign-in flow. + /// + public const string SignIn = "signin"; + + /// + /// Initiates a phone call. + /// + public const string Call = "call"; +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedActions.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedActions.cs new file mode 100644 index 00000000..87ab43e5 --- /dev/null +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/SuggestedActions.cs @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Teams.Bot.Apps.Schema; + +/// +/// Represents suggested actions that can be shown to the user as quick reply buttons. +/// +public class SuggestedActions +{ + /// + /// Gets or sets the IDs of the recipients that the actions should be shown to. + /// These IDs are relative to the channelId and a subset of all recipients of the activity. + /// + [JsonPropertyName("to")] + public IList To { get; set; } = []; + + /// + /// Gets or sets the actions that can be shown to the user. + /// + [JsonPropertyName("actions")] + public IList Actions { get; set; } = []; + + /// + /// Adds recipients to the suggested actions. + /// + /// The recipient IDs to add. + /// This instance for chaining. + public SuggestedActions AddRecipients(params string[] recipients) + { + ArgumentNullException.ThrowIfNull(recipients); + foreach (var to in recipients) + { + To.Add(to); + } + + return this; + } + + /// + /// Adds a single action to the suggested actions. + /// + /// The action to add. + /// This instance for chaining. + public SuggestedActions AddAction(SuggestedAction action) + { + Actions.Add(action); + return this; + } + + /// + /// Adds multiple actions to the suggested actions. + /// + /// The actions to add. + /// This instance for chaining. + public SuggestedActions AddActions(params SuggestedAction[] actions) + { + ArgumentNullException.ThrowIfNull(actions); + foreach (var action in actions) + { + Actions.Add(action); + } + + return this; + } +} diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs index 19d6fd8a..2dd904c8 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivity.cs @@ -175,6 +175,13 @@ internal TeamsActivity Rebase() [JsonPropertyName("localTimezone")] public string? LocalTimezone { get; set; } + /// + /// Gets or sets the suggested actions for the message. + /// + [JsonPropertyName("suggestedActions")] + public SuggestedActions? SuggestedActions { get; set; } + + /// /// Adds an entity to the activity's Entities collection. /// diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs index 2d64217e..cb76add2 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityBuilder.cs @@ -164,6 +164,18 @@ public TeamsActivityBuilder WithText(string text, string textFormat = "plain") return this; } + /// + /// With Suggested Actions + /// + /// + /// + public TeamsActivityBuilder WithSuggestedActions(SuggestedActions suggestedActions) + { + ArgumentNullException.ThrowIfNull(_activity); + _activity.SuggestedActions = suggestedActions; + return this; + } + /// /// Adds a mention to the activity. /// diff --git a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs index e56cab6c..a7380000 100644 --- a/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs +++ b/core/src/Microsoft.Teams.Bot.Apps/Schema/TeamsActivityJsonContext.cs @@ -32,6 +32,8 @@ namespace Microsoft.Teams.Bot.Apps.Schema; [JsonSerializable(typeof(CitationAppearanceDocument))] [JsonSerializable(typeof(CitationImageObject))] [JsonSerializable(typeof(CitationAppearance))] +[JsonSerializable(typeof(SuggestedActions))] +[JsonSerializable(typeof(SuggestedAction))] [JsonSerializable(typeof(TeamsChannelData))] [JsonSerializable(typeof(ConversationAccount))] [JsonSerializable(typeof(TeamsConversationAccount))] diff --git a/core/test/Microsoft.Teams.Bot.Apps.UnitTests/SuggestedActionsTests.cs b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/SuggestedActionsTests.cs new file mode 100644 index 00000000..2610677e --- /dev/null +++ b/core/test/Microsoft.Teams.Bot.Apps.UnitTests/SuggestedActionsTests.cs @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Teams.Bot.Apps.Schema; +using Microsoft.Teams.Bot.Core.Schema; + +namespace Microsoft.Teams.Bot.Apps.UnitTests; + +public class SuggestedActionsTests +{ + [Fact] + public void ActionTypes_Constants_HaveExpectedValues() + { + Assert.Equal("openUrl", ActionType.OpenUrl); + Assert.Equal("imBack", ActionType.IMBack); + Assert.Equal("postBack", ActionType.PostBack); + Assert.Equal("playAudio", ActionType.PlayAudio); + Assert.Equal("playVideo", ActionType.PlayVideo); + Assert.Equal("showImage", ActionType.ShowImage); + Assert.Equal("downloadFile", ActionType.DownloadFile); + Assert.Equal("signin", ActionType.SignIn); + Assert.Equal("call", ActionType.Call); + } + + [Fact] + public void SuggestedAction_DefaultConstructor_AllPropertiesNull() + { + var action = new SuggestedAction(); + + Assert.Null(action.Type); + Assert.Null(action.Title); + Assert.Null(action.Image); + Assert.Null(action.Text); + Assert.Null(action.DisplayText); + Assert.Null(action.Value); + Assert.Null(action.ChannelData); + Assert.Null(action.ImageAltText); + } + + [Fact] + public void SuggestedAction_ConvenienceConstructor_SetsTypeAndTitle() + { + var action = new SuggestedAction(ActionType.IMBack, "Say Hello"); + + Assert.Equal(ActionType.IMBack, action.Type); + Assert.Equal("Say Hello", action.Title); + } + + [Fact] + public void SuggestedActions_DefaultConstructor_EmptyCollections() + { + var suggestedActions = new SuggestedActions(); + + Assert.NotNull(suggestedActions.To); + Assert.Empty(suggestedActions.To); + Assert.NotNull(suggestedActions.Actions); + Assert.Empty(suggestedActions.Actions); + } + + [Fact] + public void SuggestedActions_AddRecipients_AddsToList() + { + var suggestedActions = new SuggestedActions(); + + suggestedActions.AddRecipients("user1", "user2"); + + Assert.Equal(2, suggestedActions.To.Count); + Assert.Contains("user1", suggestedActions.To); + Assert.Contains("user2", suggestedActions.To); + } + + [Fact] + public void SuggestedActions_AddAction_AddsToList() + { + var suggestedActions = new SuggestedActions(); + var action = new SuggestedAction(ActionType.IMBack, "Click me"); + + suggestedActions.AddAction(action); + + Assert.Single(suggestedActions.Actions); + Assert.Equal("Click me", suggestedActions.Actions[0].Title); + } + + [Fact] + public void SuggestedActions_AddActions_AddsMultiple() + { + var suggestedActions = new SuggestedActions(); + + suggestedActions.AddActions( + new SuggestedAction(ActionType.IMBack, "Option 1"), + new SuggestedAction(ActionType.IMBack, "Option 2"), + new SuggestedAction(ActionType.PostBack, "Option 3") + ); + + Assert.Equal(3, suggestedActions.Actions.Count); + } + + [Fact] + public void SuggestedActions_FluentChaining_ReturnsSameInstance() + { + var suggestedActions = new SuggestedActions(); + var action = new SuggestedAction(ActionType.IMBack, "Test"); + + var result1 = suggestedActions.AddRecipients("user1"); + var result2 = suggestedActions.AddAction(action); + var result3 = suggestedActions.AddActions(action); + + Assert.Same(suggestedActions, result1); + Assert.Same(suggestedActions, result2); + Assert.Same(suggestedActions, result3); + } + + [Fact] + public void MessageActivity_SuggestedActions_Serialize() + { + var activity = new MessageActivity("Choose an option") + { + SuggestedActions = new SuggestedActions() + }; + activity.SuggestedActions.AddRecipients("user1"); + activity.SuggestedActions.AddAction(new SuggestedAction(ActionType.IMBack, "Option 1") { Value = "opt1" }); + + string json = activity.ToJson(); + + Assert.Contains("\"suggestedActions\"", json); + Assert.Contains("\"to\"", json); + Assert.Contains("\"actions\"", json); + Assert.Contains("\"imBack\"", json); + Assert.Contains("\"Option 1\"", json); + Assert.Contains("\"opt1\"", json); + Assert.Contains("user1", json); + } + + [Fact] + public void MessageActivity_FromCoreActivity_DeserializesSuggestedActions() + { + string json = """ + { + "type": "message", + "text": "Choose an option", + "suggestedActions": { + "to": ["user1", "user2"], + "actions": [ + { + "type": "imBack", + "title": "Option 1", + "value": "option1" + }, + { + "type": "postBack", + "title": "Option 2", + "value": "option2" + } + ] + } + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + MessageActivity activity = MessageActivity.FromActivity(coreActivity); + + Assert.NotNull(activity.SuggestedActions); + Assert.Equal(2, activity.SuggestedActions.To.Count); + Assert.Contains("user1", activity.SuggestedActions.To); + Assert.Contains("user2", activity.SuggestedActions.To); + Assert.Equal(2, activity.SuggestedActions.Actions.Count); + Assert.Equal("imBack", activity.SuggestedActions.Actions[0].Type); + Assert.Equal("Option 1", activity.SuggestedActions.Actions[0].Title); + Assert.Equal("postBack", activity.SuggestedActions.Actions[1].Type); + Assert.Equal("Option 2", activity.SuggestedActions.Actions[1].Title); + } + + [Fact] + public void MessageActivity_WithoutSuggestedActions_PropertyIsNull() + { + string json = """ + { + "type": "message", + "text": "No suggestions here" + } + """; + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + MessageActivity activity = MessageActivity.FromActivity(coreActivity); + + Assert.Null(activity.SuggestedActions); + } + + [Fact] + public void MessageActivity_WithSuggestedActions_SetsProperty() + { + var suggestedActions = new SuggestedActions(); + + var activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Choose an option") + .WithSuggestedActions(suggestedActions) + .Build(); + + Assert.NotNull(activity.SuggestedActions); + Assert.Same(suggestedActions, activity.SuggestedActions); + Assert.Empty(activity.SuggestedActions.Actions); + } + + + + [Fact] + public void MessageActivity_WithSuggestedActions() + { + var suggestedActions = new SuggestedActions() + .AddAction(new SuggestedAction(ActionType.IMBack, "Option 1") { Value = "opt1" }); + + var activity = TeamsActivity.CreateBuilder() + .WithType(TeamsActivityType.Message) + .WithText("Choose an option") + .WithSuggestedActions(suggestedActions) + .Build(); + + Assert.NotNull(activity.SuggestedActions); + Assert.Same(suggestedActions, activity.SuggestedActions); + Assert.Single(activity.SuggestedActions.Actions); + + Assert.NotNull(activity.SuggestedActions); + Assert.Empty(activity.SuggestedActions.To); + } + + [Fact] + public void MessageActivity_SuggestedActions_RoundTrip() + { + var activity = new MessageActivity("Choose"); + activity.SuggestedActions = new SuggestedActions(); + activity.SuggestedActions.AddRecipients("user1"); + activity.SuggestedActions.AddActions( + new SuggestedAction(ActionType.OpenUrl, "Open") { Value = "https://example.com" }, + new SuggestedAction(ActionType.IMBack, "Say Hi") { Value = "hi" } + ); + + string json = activity.ToJson(); + + CoreActivity coreActivity = CoreActivity.FromJsonString(json); + MessageActivity roundTripped = MessageActivity.FromActivity(coreActivity); + + Assert.NotNull(roundTripped.SuggestedActions); + Assert.Single(roundTripped.SuggestedActions.To); + Assert.Equal("user1", roundTripped.SuggestedActions.To[0]); + Assert.Equal(2, roundTripped.SuggestedActions.Actions.Count); + Assert.Equal("openUrl", roundTripped.SuggestedActions.Actions[0].Type); + Assert.Equal("Open", roundTripped.SuggestedActions.Actions[0].Title); + Assert.Equal("imBack", roundTripped.SuggestedActions.Actions[1].Type); + Assert.Equal("Say Hi", roundTripped.SuggestedActions.Actions[1].Title); + } +}