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

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

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

- Bot アダプター
- アプリケーション ID
- ConversationReference

## Bot アダプター

[こちらの記事](https://qiita.com/kenakamu/items/55c93c044a9ee99bbe22) で説明した通り、BotBuilder からチャネルに対する Activity の送受信はアダプター経由で行うため、プロアクティブメッセージの送信にもアダプターが必要となります。

尚、アダプターを取得する方法はいくつかあります。

**Bot コード内**
Bot コード内から取得する場合は TurnContext.Adapter で取得可能。

**Bot コード外**
Web API のコントローラーなどから取得したい場合は IoC に登録されているものを取得。

```csharp
(BotFrameworkAdapter)serviceProvider.GetService(typeof(IAdapterIntegration));
```
実際に登録されているのは Microsoft.Bot.Builder.Integration.AspNet.Core の TryAddBotFrameworkAdapterIntegration メソッドにある。今回はこちらの方法でアダプターを取得。

[GitHub: ServiceCollectionExtensions.cs](https://github.com/Microsoft/botbuilder-dotnet/blob/master/libraries/integration/Microsoft.Bot.Builder.Integration.AspNet.Core/ServiceCollectionExtensions.cs)

## アプリケーション ID

アダプターが通信する先は Bot Connector となるため、どのアプリケーションに対して通信しているかを示すアプリケーション ID が必要となります。

## ConversationReference

プロアクティブメッセージを送信するユーザーと会話の特定のために、ConversationReference を使います。これは会話で受信した Activity から取得することが出来ます。

## ContinueConversationAsync メソッド

上記の情報を使って、アダプターの ContinueConversationAsync メソッドでプロアクティブメッセージを送信します。

```csharp
await adapter.ContinueConversationAsync(
    "ApplicationId",
    ConversationReference,
    async (turnContext, token) =>
    {
        await turnContext.SendActivityAsync("プロアクティブメッセージ");
    },
    CancellationToken.None);
```
# プログラムの実装

今回は以下のシナリオを実装してみます。

- 予定を取得した際に通知を設定できるようにする
- 通知は外部から呼び出せるよう、Web API として公開する

実装する要素は以下の通りです。

- 通知の内容を保持するクラスと全ての通知を保持するデータベースの追加
- 予定取得時に通知をするか確認するロジック追加
- 外部から通知を実行できる Web API の実装

## 通知の内容を保持するクラスと全ての通知を保持するデータベースの追加

1\. まず通知内容を保持するクラスを追加。Models フォルダに ScheduleNotification.cs ファイルを追加して、以下のコードを貼り付け。

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

```csharp:ScheduleNotificationStore.cs
using System.Collections.Generic;

public class ScheduleNotificationStore : List<ScheduleNotification>
{
}
```

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

```csharp
// 通知を保持するデータベースをシングルトンとして追加
services.AddSingleton(sp => new ScheduleNotificationStore());
```

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

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

```csharp: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 の初期化を追加。

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

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

```xml: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>
```

```xml: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 メソッドを以下コードに差し替え。

```csharp
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
    app.UseBotFramework();
    app.UseMvc();
}
```

2\. 同じく Startup.cs の ConfigureServices メソッド最後に以下コードを追加。

```csharp
// MVC 有効化
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
// BotConfiguration を IoC に追加
services.AddSingleton(sp => botConfig);
```

3\. Controllers フォルダをプロジェクトに追加し、NotificationsController.cs を追加。以下のコードを張り付け。

```csharp: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: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>
```
```xml: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 にリソースアイテムを追加。

```xml
<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](https://qiita-image-store.s3.amazonaws.com/0/214116/63d415c5-8fe7-8be8-3c9a-a025be22db8f.png)

2\. Visual Studio で F5 を押下して実行。シミュレーターより接続。言語は「日本語」を選択。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/b420fa2b-c0aa-6009-63ad-d978fe16d29d.png)

3\. 予定の確認まで実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/6c5c035a-8fae-bc0b-605f-292cc1eff09f.png)

4\. 通知したい予定を選択。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/201a763d-ac93-c999-be1e-dcb466a5b28b.png)

5\. Postman や Curl などで通知 API に Post を実行。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/306dd862-01e7-0e1f-40de-d1ca3f544e44.png)

6\. エミュレーターに通知が来ることを確認。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/214116/dad34f08-cd6b-e8ff-7a10-71414cf7a5d6.png)

# まとめ

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

[次の記事へ]
[目次へ戻る](https://qiita.com/kenakamu/items/6dc043cfc1f199032883)

[この記事のサンプルコード](https://github.com/kenakamu/botbuilderv4completeguide/tree/article19)