エンジニアとしての市場価値を測りませんか?PR

企業からあなたに合ったオリジナルのスカウトを受け取って、市場価値を測りましょう

2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Posted at

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

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

  • 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 の取得など、少し工夫が必要ですが、プロアクティブメッセージはより高度なボットのためには必須のため、是非実装を検討してください。

[次の記事へ]
目次へ戻る

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?