Push通知
BotFramework
Botbuilder
Proactive

Bot Builder v4 でボット開発 : プロアクティブメッセージを送信する

これまではユーザーからのメッセージをトリガーにボットが応答するシナリオを見てきましたが、今回はボット側からメッセージを送る、プロアクティブメッセージについて見ていきます。


プロアクティブメッセージ送信に必要なもの


  • 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 ファイルを追加して、以下のコードを貼り付け。


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 を追加し、以下のコードを張り付け。


ScheduleNotificationStore.cs

using System.Collections.Generic;

public class ScheduleNotificationStore : List<ScheduleNotification>
{
}


3. 作成したクラスをシングルトンとして使えるよう Startup.cs の ConfigureServices メソッドに以下コードを追加。

// 通知を保持するデータベースをシングルトンとして追加

services.AddSingleton(sp => new ScheduleNotificationStore());


予定取得時に通知をするか確認するロジック追加

1. 取得した予定を一時的に保存できるよう、MyStateAccessors.cs に Events プロパティを追加。


MyStateAccessors.cs

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 を取得。


ScheduleDialog.cs

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 ファイルに追加のリソースを設定。


ScheduleDialog.en.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>


ScheduleDialog.ja.resx

<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 を追加。以下のコードを張り付け。


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 フォルダにコントローラー用のリソースファイルを追加。


NotificationsController.en.resx

<?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>


NotificationsController.ja.resx

<?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 で予定を追加。通知の時間を現在時間より少し前に設定。

image.png

2. Visual Studio で F5 を押下して実行。シミュレーターより接続。言語は「日本語」を選択。

image.png

3. 予定の確認まで実行。

image.png

4. 通知したい予定を選択。

image.png

5. Postman や Curl などで通知 API に Post を実行。

image.png

6. エミュレーターに通知が来ることを確認。

image.png


まとめ

アダプターの取得の仕方やアプリケーション ID の取得など、少し工夫が必要ですが、プロアクティブメッセージはより高度なボットのためには必須のため、是非実装を検討してください。

[次の記事へ]

目次へ戻る

この記事のサンプルコード