※2017/6/10 テストについてやっぱり追記
今回はグローバルメッセージについて紹介します。
概要
ユーザーは開発者の意図通りに話してくれるか分からないという話は既にしました。途中でキャンセルしたいかもしれないし、急遽別のことを確認したいかもしれません。そのような場合は、特定のキーワードで現在の処理を中断したりキャンセルできるようにします。Bot Framework ではこのような処理またはキーワードをグローバルメッセージと呼んでいます。
処理のキャンセル
1. まずキャンセルのキーワードを登録。ボットアプリプロジェクトの Dialogs フォルダに、CancelScorable.cs を作成し、以下のコードと差し替えます。キーワードは多言語対応ということでリソースを使っています。
using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder.Dialogs.Internals; using Microsoft.Bot.Builder.Internals.Fibers; using Microsoft.Bot.Connector; using Microsoft.Bot.Builder.Scorables.Internals; using O365Bot.Resources; namespace O365Bot.Dialogs { #pragma warning disable 1998 public class CancelScorable : ScorableBase<IActivity, string, double> { private readonly IDialogTask task; public CancelScorable(IDialogTask task) { SetField.NotNull(out this.task, nameof(task), task); } /// <summary> /// キーワードと比較して、一致すればキーワードを返す /// </summary> protected override async Task<string> PrepareAsync(IActivity activity, CancellationToken token) { var message = activity as IMessageActivity; if (message != null && !string.IsNullOrWhiteSpace(message.Text)) { if (message.Text.Equals(O365BotLabel.Cancel, StringComparison.InvariantCultureIgnoreCase)) { return message.Text; } } return null; } protected override bool HasScore(IActivity item, string state) { return state != null; } protected override double GetScore(IActivity item, string state) { return 1.0; } /// <summary> /// キーワードがあった場合の処理。ここではタスクをキャンセル /// </summary> protected override async Task PostAsync(IActivity item, string state, CancellationToken token) { this.task.Reset(); } protected override Task DoneAsync(IActivity item, string state, CancellationToken token) { return Task.CompletedTask; } } }
2. 次にグローバルメッセージのハンドラーを登録。ボットアプリプロジェクトに GlobalMessageHandles.cs ファイルを追加し、コードを以下と差し替え。基本的にはここに Scorable を継承したクラスを登録していきます。
using Autofac; using O365Bot.Dialogs; using Microsoft.Bot.Builder.Dialogs.Internals; using Microsoft.Bot.Builder.Scorables; using Microsoft.Bot.Connector; namespace O365Bot.Handlers { public class GlobalMessageHandlers : Module { protected override void Load(ContainerBuilder builder) { base.Load(builder); builder .Register(c => new CancelScorable(c.Resolve<IDialogTask>())) .As<IScorable<IActivity, double>>() .InstancePerLifetimeScope(); } } }
3. アプリ起動時に読み込まれるよう、Global.asax.cs を以下のコードに差し替え。以前 IoC 用に登録した際と異なり、BotBuilder がもともと持っている Container に情報をマージする形をとっています。
using Autofac; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Internals.Fibers; using O365Bot.Handlers; using O365Bot.Services; using System.Configuration; using System.Web.Http; namespace O365Bot { public class WebApiApplication : System.Web.HttpApplication { public static IContainer Container; protected void Application_Start() { this.RegisterBotModules(); GlobalConfiguration.Configure(WebApiConfig.Register); AuthBot.Models.AuthSettings.Mode = ConfigurationManager.AppSettings["ActiveDirectory.Mode"]; AuthBot.Models.AuthSettings.EndpointUrl = ConfigurationManager.AppSettings["ActiveDirectory.EndpointUrl"]; AuthBot.Models.AuthSettings.Tenant = ConfigurationManager.AppSettings["ActiveDirectory.Tenant"]; AuthBot.Models.AuthSettings.RedirectUrl = ConfigurationManager.AppSettings["ActiveDirectory.RedirectUrl"]; AuthBot.Models.AuthSettings.ClientId = ConfigurationManager.AppSettings["ActiveDirectory.ClientId"]; AuthBot.Models.AuthSettings.ClientSecret = ConfigurationManager.AppSettings["ActiveDirectory.ClientSecret"]; var builder = new ContainerBuilder(); builder.RegisterType<GraphService>().As<IEventService>(); Container = builder.Build(); } private void RegisterBotModules() { var builder = new ContainerBuilder(); builder.RegisterModule(new ReflectionSurrogateModule()); builder.RegisterModule<GlobalMessageHandlers>(); builder.Update(Conversation.Container); } } }
エミュレーターで検証
意図した通りに動きます。興味があればデバッグして、どのように判定されているか見てください。
処理の割り込み
キャンセルではなく、処理を単に差し込見たい場合は、以下のようにします。
1. Dialogs フォルダに GetEventsScorable.cs を追加して、コードを以下に差し替え。キャンセルとことなり、GetEventDialog を差し込んでいます。 using System; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Dialogs.Internals; using Microsoft.Bot.Builder.Internals.Fibers; using Microsoft.Bot.Connector; using Microsoft.Bot.Builder.Scorables.Internals; using O365Bot.Resources; namespace O365Bot.Dialogs { #pragma warning disable 1998 public class GetEventsScorable : ScorableBase<IActivity, string, double> { private readonly IDialogTask task; public GetEventsScorable(IDialogTask task) { SetField.NotNull(out this.task, nameof(task), task); } protected override async Task<string> PrepareAsync(IActivity activity, CancellationToken token) { var message = activity as IMessageActivity; if (message != null && !string.IsNullOrWhiteSpace(message.Text)) { if (message.Text.Equals(O365BotLabel.Get_Events, StringComparison.InvariantCultureIgnoreCase)) { return message.Text; } } return null; } protected override bool HasScore(IActivity item, string state) { return state != null; } protected override double GetScore(IActivity item, string state) { return 1.0; } /// <summary> /// キーワードが一致した際、処理を差し込み。 /// </summary> protected override async Task PostAsync(IActivity item, string state, CancellationToken token) { var message = item as IMessageActivity; if (message != null) { var getEventsDialog = new GetEventsDialog(); var interruption = getEventsDialog.Void<bool, IMessageActivity>(); await this.task.Forward(interruption, null, message, CancellationToken.None); await this.task.PollAsync(token); } } protected override Task DoneAsync(IActivity item, string state, CancellationToken token) { return Task.CompletedTask; } } }
2. GlobalMessageHandlers.cs の Load メソッドに以下コードを追加。
builder .Register(c => new GetEventsScorable(c.Resolve<IDialogTask>())) .As<IScorable<IActivity, double>>() .InstancePerLifetimeScope();
エミュレーターで検証
イベント取得が差し込まれ、またイベント作成に戻っています。
テストの更新
ユニットテスト
ユニットテストでは、Global.asax.cs に登録した内容を含める必要があります。影響はすべてのテストにありますが、処理内容は同じです。以下のコードに UniteTest1.cs を入れ替えます。ポイントはグローバルメッセージの差し込み先の IContainer を間違えないことです。
using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Tests; using Microsoft.Bot.Connector; using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Threading.Tasks; using Autofac; using O365Bot.Dialogs; using Microsoft.Bot.Builder.Dialogs.Internals; using Microsoft.Bot.Builder.Base; using System.Threading; using System.Collections.Generic; using Microsoft.QualityTools.Testing.Fakes; using O365Bot.Services; using Moq; using Microsoft.Graph; using O365Bot.Resources; using System.Globalization; using O365Bot.Handlers; using Microsoft.Bot.Builder.Internals.Fibers; namespace O365Bot.UnitTests { [TestClass] public class SampleDialogTest : DialogTestBase { private string locale = "ja-JP"; public SampleDialogTest() { Thread.CurrentThread.CurrentCulture = new CultureInfo(locale); Thread.CurrentThread.CurrentUICulture = new CultureInfo(locale); } [TestMethod] public async Task ShouldReturnEvents() { // Fakes を使うためにコンテキストを作成 using (ShimsContext.Create()) { // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定 AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // サービスのモック var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.GetEvents()).ReturnsAsync(new List<Event>() { new Event { Subject = "dummy event", Start = new DateTimeTimeZone() { DateTime = "2017-05-31 12:00", TimeZone = "Standard Tokyo Time" }, End = new DateTimeTimeZone() { DateTime = "2017-05-31 13:00", TimeZone = "Standard Tokyo Time" } } }); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); WebApiApplication.Container = builder.Build(); // テストしたいダイアログのインスタンス作成 IDialog<object> rootDialog = new RootDialog(); // Bot に送るメッセージを作成 var toBot = DialogTestBase.MakeTestMessage(); toBot.From.Id = Guid.NewGuid().ToString(); // Locale 設定 toBot.Locale = "ja-JP"; toBot.Text = "get appointments"; // メモリ内で実行できる環境を作成 Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(rootDialog)) using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog)) { // グローバルメッセージ登録 RegisterBotModules(container); // メッセージを送信して、結果を受信 IMessageActivity toUser = await GetResponse(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser.Text.Equals("2017-05-31 12:00-2017-05-31 13:00: dummy event")); } } } [TestMethod] public async Task ShouldCreateAllDayEvent() { // Fakes を使うためにコンテキストを作成 using (ShimsContext.Create()) { // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定 AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // サービスのモック var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true)); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); WebApiApplication.Container = builder.Build(); // テストしたいダイアログのインスタンス作成 IDialog<object> rootDialog = new RootDialog(); // メモリ内で実行できる環境を作成 Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(rootDialog)) using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog)) { // グローバルメッセージ登録 RegisterBotModules(container); // Bot に送るメッセージを作成 var toBot = DialogTestBase.MakeTestMessage(); // ロケールで日本語を指定 toBot.Locale = locale; toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "add appointment"; // メッセージを送信して、結果を受信 var toUser = await GetResponses(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); toBot.Text = "件名"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST)); toBot.Text = "詳細"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST)); toBot.Text = "2017/06/06 13:00"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue((toUser[0].Attachments[0].Content as HeroCard).Text.Equals(O365Bot_Models_OutlookEvent.IsAllDay_promptDefinition_LIST.Replace("{||}",""))); toBot.Text = "はい"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365BotLabel.Event_Created)); } } } [TestMethod] public async Task ShouldCreateEvent() { // Fakes を使うためにコンテキストを作成 using (ShimsContext.Create()) { // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定 AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // サービスのモック var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true)); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); WebApiApplication.Container = builder.Build(); // テストしたいダイアログのインスタンス作成 IDialog<object> rootDialog = new RootDialog(); // メモリ内で実行できる環境を作成 Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(rootDialog)) using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog)) { // グローバルメッセージ登録 RegisterBotModules(container); // Bot に送るメッセージを作成 var toBot = DialogTestBase.MakeTestMessage(); // ロケールで日本語を指定 toBot.Locale = locale; toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "add appointment"; // メッセージを送信して、結果を受信 var toUser = await GetResponses(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); toBot.Text = "件名"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST)); toBot.Text = "詳細"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST)); toBot.Text = "2017/06/06 13:00"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue((toUser[0].Attachments[0].Content as HeroCard).Text.Equals(O365Bot_Models_OutlookEvent.IsAllDay_promptDefinition_LIST.Replace("{||}",""))); toBot.Text = "いいえ"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Hours_promptDefinition_LIST)); toBot.Text = "4"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365BotLabel.Event_Created)); } } } [TestMethod] public async Task ShouldCancelCurrrentDialog() { // Fakes を使うためにコンテキストを作成 using (ShimsContext.Create()) { // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定 AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // サービスのモック var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true)); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); WebApiApplication.Container = builder.Build(); // テストしたいダイアログのインスタンス作成 IDialog<object> rootDialog = new RootDialog(); // メモリ内で実行できる環境を作成 Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(rootDialog)) using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog)) { // グローバルメッセージ登録 RegisterBotModules(container); // Bot に送るメッセージを作成 var toBot = DialogTestBase.MakeTestMessage(); // ロケールで日本語を指定 toBot.Locale = locale; toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "add appointment"; // メッセージを送信して、結果を受信 var toUser = await GetResponses(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); toBot.Text = "件名"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST)); toBot.Text = O365BotLabel.Cancel; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser.Count.Equals(0)); toBot.Text = "add appointment"; toUser = await GetResponses(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); } } } [TestMethod] public async Task ShouldInterruptCurrentDialog() { // Fakes を使うためにコンテキストを作成 using (ShimsContext.Create()) { // AuthBot の GetAccessToken メソッドを実行した際、dummyToken というトークンが返るよう設定 AuthBot.Fakes.ShimContextExtensions.GetAccessTokenIBotContextString = async (a, e) => { return "dummyToken"; }; // サービスのモック var mockEventService = new Mock<IEventService>(); mockEventService.Setup(x => x.CreateEvent(It.IsAny<Event>())).Returns(Task.FromResult(true)); mockEventService.Setup(x => x.GetEvents()).ReturnsAsync(new List<Event>() { new Event { Subject = "dummy event", Start = new DateTimeTimeZone() { DateTime = "2017-05-31 12:00", TimeZone = "Standard Tokyo Time" }, End = new DateTimeTimeZone() { DateTime = "2017-05-31 13:00", TimeZone = "Standard Tokyo Time" } } }); var builder = new ContainerBuilder(); builder.RegisterInstance(mockEventService.Object).As<IEventService>(); WebApiApplication.Container = builder.Build(); // テストしたいダイアログのインスタンス作成 IDialog<object> rootDialog = new RootDialog(); // メモリ内で実行できる環境を作成 Func<IDialog<object>> MakeRoot = () => rootDialog; using (new FiberTestBase.ResolveMoqAssembly(rootDialog)) using (var container = Build(Options.MockConnectorFactory | Options.ScopedQueue, rootDialog)) { // グローバルメッセージ登録 RegisterBotModules(container); // Bot に送るメッセージを作成 var toBot = DialogTestBase.MakeTestMessage(); // ロケールで日本語を指定 toBot.Locale = locale; toBot.From.Id = Guid.NewGuid().ToString(); toBot.Text = "add appointment"; // メッセージを送信して、結果を受信 var toUser = await GetResponses(container, MakeRoot, toBot); // 結果の検証 Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); toBot.Text = "件名"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST)); toBot.Text = O365BotLabel.Get_Events; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals("2017-05-31 12:00-2017-05-31 13:00: dummy event")); toBot.Text = "詳細"; toUser = await GetResponses(container, MakeRoot, toBot); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST)); } } } /// <summary> /// グローバルメッセージ登録 /// </summary> private void RegisterBotModules(IContainer container) { var builder = new ContainerBuilder(); builder.RegisterModule(new ReflectionSurrogateModule()); builder.RegisterModule<GlobalMessageHandlers>(); builder.Update(container); } /// <summary> /// Bot にメッセージを送って、結果を受信 /// </summary> public async Task<IMessageActivity> GetResponse(IContainer container, Func<IDialog<object>> makeRoot, IMessageActivity toBot) { using (var scope = DialogModule.BeginLifetimeScope(container, toBot)) { DialogModule_MakeRoot.Register(scope, makeRoot); // act: sending the message using (new LocalizedScope(toBot.Locale)) { var task = scope.Resolve<IPostToBot>(); await task.PostAsync(toBot, CancellationToken.None); } //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None); return scope.Resolve<Queue<IMessageActivity>>().Dequeue(); } } /// <summary> /// Bot にメッセージを送って、結果を受信 /// </summary> public async Task<List<IMessageActivity>> GetResponses(IContainer container, Func<IDialog<object>> makeRoot, IMessageActivity toBot) { using (var scope = DialogModule.BeginLifetimeScope(container, toBot)) { var results = new List<IMessageActivity>(); DialogModule_MakeRoot.Register(scope, makeRoot); // act: sending the message using (new LocalizedScope(toBot.Locale)) { var task = scope.Resolve<IPostToBot>(); await task.PostAsync(toBot, CancellationToken.None); } //await Conversation.SendAsync(toBot, makeRoot, CancellationToken.None); var queue= scope.Resolve<Queue<IMessageActivity>>(); while(queue.Count != 0) { results.Add(queue.Dequeue()); } return results; } } } }
ファンクションテスト
以下 2 つのテストを追加します。
[TestMethod] public void Function_ShouldCancelCurrrentDialog() { DirectLineHelper helper = new DirectLineHelper(TestContext); var toUser = helper.SentMessage("add appointment"); // 結果の検証 Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); toUser = helper.SentMessage("件名"); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST)); toUser = helper.SentMessage(O365BotLabel.Cancel); Assert.IsTrue(toUser.Count.Equals(0)); toUser = helper.SentMessage("add appointment"); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); } [TestMethod] public void Function_ShouldInterruptCurrentDialog() { DirectLineHelper helper = new DirectLineHelper(TestContext); var toUser = helper.SentMessage("add appointment"); // 結果の検証 Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.message0_LIST)); Assert.IsTrue(toUser[1].Text.Equals(O365Bot_Models_OutlookEvent.Subject_promptDefinition_LIST)); toUser = helper.SentMessage("件名"); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Description_promptDefinition_LIST)); toUser = helper.SentMessage(O365BotLabel.Get_Events); Assert.IsTrue(true); toUser = helper.SentMessage("詳細"); Assert.IsTrue(toUser[0].Text.Equals(O365Bot_Models_OutlookEvent.Start_promptDefinition_LIST)); }
まとめ
結構簡単にグローバルメッセージを組み込めました。これでメニューに関連するキーワード来たら処理をやり直しなど、柔軟な対応ができますね。次回はボットからプロアクティブに話しかけるシナリオを紹介します。
Join the conversation
Add Comment