これまではユーザーからのメッセージをトリガーにボットが応答するシナリオを見てきましたが、今回はボット側からメッセージを送る、プロアクティブメッセージについて見ていきます。
プロアクティブメッセージ送信に必要なもの
- Bot アダプター
- アプリケーション ID
- ConversationReference
Bot アダプター
こちらの記事 で説明した通り、BotBuilder からチャネルに対する Activity の送受信はアダプター経由で行うため、プロアクティブメッセージの送信にもアダプターが必要となります。
尚、アダプターを取得する方法はいくつかあります。
Bot コード内
Bot コード内から取得する場合は TurnContext.Adapter で取得可能。
Bot コード外
Web API のコントローラーなどから取得したい場合は IoC に登録されているものを取得。
(BotFrameworkAdapter)serviceProvider.GetService(typeof(IAdapterIntegration));
実際に登録されているのは Microsoft.Bot.Builder.Integration.AspNet.Core の TryAddBotFrameworkAdapterIntegration メソッドにある。今回はこちらの方法でアダプターを取得。
GitHub: ServiceCollectionExtensions.cs
アプリケーション ID
アダプターが通信する先は Bot Connector となるため、どのアプリケーションに対して通信しているかを示すアプリケーション ID が必要となります。
ConversationReference
プロアクティブメッセージを送信するユーザーと会話の特定のために、ConversationReference を使います。これは会話で受信した Activity から取得することが出来ます。
ContinueConversationAsync メソッド
上記の情報を使って、アダプターの ContinueConversationAsync メソッドでプロアクティブメッセージを送信します。
await adapter.ContinueConversationAsync(
"ApplicationId",
ConversationReference,
async (turnContext, token) =>
{
await turnContext.SendActivityAsync("プロアクティブメッセージ");
},
CancellationToken.None);
プログラムの実装
今回は以下のシナリオを実装してみます。
- 予定を取得した際に通知を設定できるようにする
- 通知は外部から呼び出せるよう、Web API として公開する
実装する要素は以下の通りです。
- 通知の内容を保持するクラスと全ての通知を保持するデータベースの追加
- 予定取得時に通知をするか確認するロジック追加
- 外部から通知を実行できる Web API の実装
通知の内容を保持するクラスと全ての通知を保持するデータベースの追加
1. まず通知内容を保持するクラスを追加。Models フォルダに ScheduleNotification.cs ファイルを追加して、以下のコードを貼り付け。
using System;
using Microsoft.Bot.Schema;
public class ScheduleNotification
{
// 通知をする時間
public DateTime NotificationTime { get; set; }
// 実際の予定開始時間
public DateTime StartTime { get; set; }
// 予定のタイトル
public string Title { get; set; }
// 予定へのリンク
public string WebLink { get; set; }
public ConversationReference ConversationReference { get; set; }
}
2. 次にデータベースとなるクラスを追加。Services フォルダに ScheduleNotificationStore.cs を追加し、以下のコードを張り付け。
using System.Collections.Generic;
public class ScheduleNotificationStore : List<ScheduleNotification>
{
}
3. 作成したクラスをシングルトンとして使えるよう Startup.cs の ConfigureServices メソッドに以下コードを追加。
// 通知を保持するデータベースをシングルトンとして追加
services.AddSingleton(sp => new ScheduleNotificationStore());
予定取得時に通知をするか確認するロジック追加
1. 取得した予定を一時的に保存できるよう、MyStateAccessors.cs に Events プロパティを追加。
using System;
using System.Collections.Generic;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
public class MyStateAccessors
{
public MyStateAccessors(
UserState userState,
ConversationState conversationState)
{
UserState = userState ?? throw new ArgumentNullException(nameof(userState));
ConversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState));
}
public IStatePropertyAccessor<UserProfile> UserProfile { get; set; }
public IStatePropertyAccessor<DialogState> ConversationDialogState { get; set; }
public IStatePropertyAccessor<IList<Microsoft.Graph.Event>> Events { get; set; }
public UserState UserState { get; }
public ConversationState ConversationState { get; }
}
2. Startup.cs の ConfigureServices メソッドで MyStateAccessors の登録をしている箇所で Events の初期化を追加。
// MyStateAccessors を IoC コンテナに登録
services.AddSingleton(sp =>
{
// AddBot で登録した options を取得。
var options = sp.GetRequiredService<IOptions<BotFrameworkOptions>>().Value;
if (options == null)
{
throw new InvalidOperationException("BotFrameworkOptions を事前に構成してください。");
}
var userState = options.State.OfType<UserState>().FirstOrDefault();
if (userState == null)
{
throw new InvalidOperationException("UserState を事前に定義してください。");
}
var conversationState = options.State.OfType<ConversationState>().FirstOrDefault();
if (conversationState == null)
{
throw new InvalidOperationException("ConversationState を事前に定義してください。");
}
var accessors = new MyStateAccessors(userState, conversationState)
{
// DialogState を作成
ConversationDialogState = conversationState.CreateProperty<DialogState>("DialogState"),
// Events を作成
Events = conversationState.CreateProperty<IList<Microsoft.Graph.Event>>("Events"),
// UserProfile を作成
UserProfile = userState.CreateProperty<UserProfile>("UserProfile"),
};
return accessors;
});
3. ScheduleDialog.cs に予定取得後に通知したい予定を選択するか、通知が不要である旨を追加できるようロジックを追加。
- コンストラクタで依存関係として ScheduleNotificationStore と MyStateAccessors を取得。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Dialogs;
using Microsoft.Bot.Builder.Dialogs.Choices;
using Microsoft.Bot.Schema;
using Microsoft.Extensions.Localization;
public class ScheduleDialog : ComponentDialog
{
private IStringLocalizer<ScheduleDialog> localizer;
private ScheduleNotificationStore scheduleNotificationStore;
private MyStateAccessors accessors;
public ScheduleDialog(MyStateAccessors accessors, IServiceProvider serviceProvider, IStringLocalizer<ScheduleDialog> localizer, ScheduleNotificationStore scheduleNotificationStore) : base(nameof(ScheduleDialog))
{
this.accessors = accessors;
this.localizer = localizer;
this.scheduleNotificationStore = scheduleNotificationStore;
// ウォーターフォールのステップを定義。処理順にメソッドを追加。
var waterfallSteps = new WaterfallStep[]
{
LoginAsync,
GetScheduleAsync,
ProcessChoiceInputAsync,
};
// ウォーターフォールダイアログと各種プロンプトを追加
AddDialog(new WaterfallDialog("schedule", waterfallSteps));
AddDialog((LoginDialog)serviceProvider.GetService(typeof(LoginDialog)));
AddDialog(new ChoicePrompt("choice"));
}
private async Task<DialogTurnResult> LoginAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
return await stepContext.BeginDialogAsync(nameof(LoginDialog), cancellationToken: cancellationToken);
}
private async Task<DialogTurnResult> GetScheduleAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
// ログインの結果よりトークンを取得
var accessToken = (string)stepContext.Result;
if (!string.IsNullOrEmpty(accessToken))
{
var graphClient = new MSGraphService(accessToken);
var events = await graphClient.GetScheduleAsync();
if( events.Count() > 0)
{
events.ForEach(async x =>
{
await stepContext.Context.SendActivityAsync($"{System.DateTime.Parse(x.Start.DateTime).ToString("HH:mm")}-{System.DateTime.Parse(x.End.DateTime).ToString("HH:mm")} : {x.Subject}", cancellationToken: cancellationToken);
});
// ステートに取得した予定を保存
await accessors.Events.SetAsync(stepContext.Context, events, cancellationToken);
// Choice プロンプトで予定の一覧と通知不要の選択肢を表示
var choices = ChoiceFactory.ToChoices(events.Select(x => $"{DateTime.Parse(x.Start.DateTime).ToString("HH:mm")}-{x.Subject}").ToList());
choices.Add(new Choice(localizer["nonotification"]));
return await stepContext.PromptAsync(
"choice",
new PromptOptions
{
Prompt = MessageFactory.Text(localizer["setnotification"]),
Choices = choices,
},
cancellationToken);
}
else
{
await stepContext.Context.SendActivityAsync(localizer["noevents"], cancellationToken: cancellationToken);
return await stepContext.EndDialogAsync(true, cancellationToken);
}
}
else
{
await stepContext.Context.SendActivityAsync(localizer["failed"], cancellationToken: cancellationToken);
return await stepContext.EndDialogAsync(false, cancellationToken);
}
}
private async Task<DialogTurnResult> ProcessChoiceInputAsync(WaterfallStepContext stepContext, CancellationToken cancellationToken)
{
// 答えを確認して予定を取得
var choice = (FoundChoice)stepContext.Result;
var events = await accessors.Events.GetAsync(stepContext.Context, null, cancellationToken);
var @event = events.Where(x=> $"{DateTime.Parse(x.Start.DateTime).ToString("HH:mm")}-{x.Subject}" == choice.Value).FirstOrDefault();
if (@event != null)
{
// 開始時間と通知時間を取得
var start = DateTime.Parse(@event.Start.DateTime);
var reminderMinutesBeforeStartmin = @event.ReminderMinutesBeforeStart;
var notificationTime = reminderMinutesBeforeStartmin == null ? start : start.AddMinutes(-double.Parse(reminderMinutesBeforeStartmin.ToString()));
// 通知の情報を追加
scheduleNotificationStore.Add(new ScheduleNotification
{
Title = @event.Subject,
NotificationTime = notificationTime,
StartTime = start,
WebLink = @event.WebLink,
ConversationReference = stepContext.Context.Activity.GetConversationReference(),
});
await stepContext.Context.SendActivityAsync(localizer["notificationset"], cancellationToken: cancellationToken);
return await stepContext.EndDialogAsync(true, cancellationToken);
}
else
{
return await stepContext.EndDialogAsync(true, cancellationToken);
}
}
}
4. ScheduleDialog 用の resx ファイルに追加のリソースを設定。
<data name="nonotification" xml:space="preserve">
<value>I don't need notificaiton.</value>
</data>
<data name="setnotification" xml:space="preserve">
<value>Which schedule item do you want to set notification?</value>
</data>
<data name="noevents" xml:space="preserve">
<value>There is no scheduled events.</value>
</data>
<data name="notificationset" xml:space="preserve">
<value>Set the notification.</value>
</data>
<data name="nonotification" xml:space="preserve">
<value>設定しない</value>
</data>
<data name="setnotification" xml:space="preserve">
<value>どの予定について通知しますか?</value>
</data>
<data name="noevents" xml:space="preserve">
<value>予定がありませんでした。</value>
</data>
<data name="notificationset" xml:space="preserve">
<value>通知を設定しました。</value>
</data>
外部から通知を実行できる Web API の実装
1. WebAPI を使える様に MVC を有効化。Startup.cs の Configure メソッドを以下コードに差し替え。
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
app.UseBotFramework();
app.UseMvc();
}
2. 同じく Startup.cs の ConfigureServices メソッド最後に以下コードを追加。
// MVC 有効化
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
// BotConfiguration を IoC に追加
services.AddSingleton(sp => botConfig);
3. Controllers フォルダをプロジェクトに追加し、NotificationsController.cs を追加。以下のコードを張り付け。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Bot.Builder;
using Microsoft.Bot.Builder.Integration;
using Microsoft.Bot.Configuration;
using Microsoft.Extensions.Localization;
[Route("api/[controller]")]
[ApiController]
public class NotificationsController : ControllerBase
{
private IServiceProvider serviceProvider;
private IStringLocalizer<NotificationsController> localizer;
private ScheduleNotificationStore scheduleNotificationStore;
private BotFrameworkAdapter adapter;
private BotConfiguration botConfig;
public NotificationsController(
IServiceProvider serviceProvider,
IStringLocalizer<NotificationsController> localizer,
BotConfiguration botConfig,
ScheduleNotificationStore scheduleNotificationStore)
{
this.serviceProvider = serviceProvider;
this.localizer = localizer;
this.botConfig = botConfig;
this.scheduleNotificationStore = scheduleNotificationStore;
this.adapter = (BotFrameworkAdapter)serviceProvider.GetService(typeof(IAdapterIntegration));
}
[HttpPost]
public ActionResult Post()
{
// 現在時刻より前の通知時間が設定されているものを全て取得
var scheduleNotifications = scheduleNotificationStore
.Where(x => x.NotificationTime.ToUniversalTime() < DateTime.UtcNow).ToList();
// 通知と削除
scheduleNotifications.ForEach(async (x) =>
{
await SendProactiveMessage(x);
DeleteCompletedNotificaitons(x);
});
return new OkObjectResult(true);
}
private async Task SendProactiveMessage(ScheduleNotification scheduleNotification)
{
// 構成ファイルより Endpoint を取得
EndpointService endpointService = (EndpointService)botConfig.Services.Where(x => x.Type == "endpoint").First();
await adapter.ContinueConversationAsync(
endpointService.AppId,
scheduleNotification.ConversationReference,
async (turnContext, token) =>
{
await turnContext.SendActivityAsync(
$"{localizer["notification"]}:{scheduleNotification.StartTime.ToString("HH:mm")} - [{scheduleNotification.Title}]({scheduleNotification.WebLink})");
},
CancellationToken.None);
}
private void DeleteCompletedNotificaitons(ScheduleNotification scheduleNotification)
{
scheduleNotificationStore.Remove(scheduleNotification);
}
}
4. Resources フォルダにコントローラー用のリソースファイルを追加。
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="notification" xml:space="preserve">
<value>Schedule Notification</value>
</data>
</root>
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="notification" xml:space="preserve">
<value>予定の通知</value>
</data>
</root>
5. myfirstbot.csproj にリソースアイテムを追加。
<EmbeddedResource Update="Resources\NotificationsController.en.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
<EmbeddedResource Update="Resources\NotificationsController.ja.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
</EmbeddedResource>
テスト
1. Office 365 Outlook で予定を追加。通知の時間を現在時間より少し前に設定。
2. Visual Studio で F5 を押下して実行。シミュレーターより接続。言語は「日本語」を選択。
5. Postman や Curl などで通知 API に Post を実行。
まとめ
アダプターの取得の仕方やアプリケーション ID の取得など、少し工夫が必要ですが、プロアクティブメッセージはより高度なボットのためには必須のため、是非実装を検討してください。
[次の記事へ]
目次へ戻る