Cisco Webex TeamsのBotで、投稿されたメッセージにリアルタイムに反応する(4分で実装)

今回もWebex Teams API Clientを使って、さくっと実装してみます。
他にもっと簡単にさくっとできそうなネタもたくさんありますが、
リアルタイムなメッセージ検知は、Cisco Webex Teams APIの醍醐味のひとつなので、
早い段階で扱っておこうと思います。

今回も、多少サバ読みしつつ、Webex Teams API Clientを使って、
ユーザがBotに対して投稿したメッセージを、リアルタイムで検知して、
そのメッセージに反応するところまでを、4分で実装してみます。

以下の記事で、**事前準備は完了しているのが前提(サバ読みの部分)**です。

* [Webex Teams API Clientを使って、Cisco Webex TeamsのBotを作ってみる(準備編)](https://qiita.com/thrzn41/items/ff049b972cbc6893e3c6)

**TeamsAPIClientのインスタンスも作成済みの前提**です。
以下の記事では、インスタンスは、`teams`という変数名で参照します。

**※トークンは、Botアカウントのトークンを使ってください。**

``` csharp
// Webex Teams API Clientのインスタンスを作成します。
TeamsAPIClient teams = TeamsAPI.CreateVersion1Client(protectedToken);
```

# 1. リアルタイムで投稿されたメッセージを検知する

**「リアルタイム」**がどのくらいかは、携わっている技術分野や目的、その人の生い立ちによっても異なると思います。
人によっては、目的によっては、1マイクロ秒ですら、永遠と思えるほど長いという方もいるでしょう。

Cisco Webex Teams APIにおけるリアルタイムは、1秒とか、その程度の時間になります。
Cisco Webex Teamsでは、いろんな要素をことごとく暗号化、復号しているので、
激速なシステムというわけではありません。

今回は実装に入る前に、ちょっとだけ細かい仕組みの部分も触れていこうと思います。
**説明や下準備は、実装時間の4分には含まれません**。


## 1-1. Webhookでメッセージの投稿を検知

Cisco Webex Teamsで、リアルタイムにメッセージの投稿を検知するには、Webhookを利用します。
Webhookは、Cisco Webex Teamsのクラウド上のAPIサービスからアクセス可能なインターネット上の
通知先をイメージするとよいと思います。

イメージ的にはこんな感じです。
**[Cisco Webex Teams] <--> [Cisco Webex Teams APIサービス] --> [Webhook]**

> 実際には、これほど単純ではないですが、まあ、単純化するとこんなもんです。

ポイントとしては、**Cisco Webex Teamsは、クラウドサービスであるので、
Webhookも含めてすべてのコンポーネントがクラウド上にあります。**

自分で作ったアプリケーションでリアルタイムに投稿されたメッセージを検知するためには、
**Webhookの通知先として、インターネット上にhttpsの通知先URLを準備しておく必要**があります。

Cisco Webex Teamsにメッセージが投稿されると、Cisco Webex Teams APIサービスは、
Webhookのhttpsの通知先URLにイベントとして通知します。
(メッセージ投稿以外にも、スペースの作成、メンバーの追加などのイベントも検知できます。)

「**インターネット上にhttpsの通知先URLを準備**」は、人によってはどうってことないかもしれませんが、
すぐに準備できない人も多いでしょう。

そんな方でも、すぐ試せるように、今回は、トンネリングサービスの１つである、
**ngrok**(エングロック)を使って実現してみます。

**ngrok**を利用すると、インターネット上のhttpsの通知先URLは、**ngrok**のサービス側で利用可能になります。
無償で利用している限りでは、1分あたり40コネクションまで利用可能です。
とりあえず、何か試してみる限りにおいては、この程度使えれば充分でしょう。


## 1-2. ngrokの入手と起動

ngrokは、[ここから入手](https://ngrok.com/)できます。
[DOWNLOAD]ページに移動して、利用している環境にあったツールを入手できます。

無償の範囲で試すのであれば、ダウンロードしたツールを起動するだけで、サービスは利用可能です。

```
command> ngrok http 8080 --bind-tls=true
```

こんな感じでツールを実行すると、インターネット上にhttpsのURLを確保して、
ローカルで実行しているマシンとの間でセキュアなコネクションを張ってくれます。
確保されたhttpsのURLへのアクセスは、
指定したローカルのポート番号(上の例では、8080)にフォワードされます。

上の実行例の場合は、このような出力になっているはずです。

```
Session Status                online
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    https://xyz-ngrok.example.com -> localhost:8080
```

> Regionは、オプションでアジアのデータセンターを指定することもできます。

この出力例の場合は、
インターネット上のURLである`https://xyz-ngrok.example.com`へのリクエストが、
ローカルマシン上の`localhost:8080`へフォワードされます。

無償の範囲でアカウント登録せずに実行している場合は、
`https://xyz-ngrok.example.com`の部分はランダムで実行ごとに異なります。

> ngrokのAPIを使うとランダムで割り当てられたURLを動的に取得もできます。
> GET http://127.0.0.1:4040/api/tunnels
> などで取得できます。

この状態で、**インターネット上にあるhttpsのWebhook通知先URLの準備はクリア**しました。
あとは、フォワードされたローカルマシン上での処理を実装すればOKです。

実装を始める前に、もう少しだけポイントがあります。

## 1-3. Cisco Webex TeamsのWebhookのセキュリティ

Webhookの通知先のhttpsのURLは、インターネット上で公開された状態です。
URLさえ分かっていれば、Cisco Webex Teams APIサービスだけではなく、誰でもアクセスできます。

通知されたメッセージが、Cisco Webex Teams APIサービスからのものかどうかを確認する必要があります。
Webex Teams API Clientでは、この確認処理は内部的に行うので、詳細は説明しませんが、
主には以下の確認を行うとよいです(別途、別の記事では詳細を扱う予定)。

* WebhookをCisco Webex Teams APIサービスに登録(登録もAPIを使って可能)する際にランダムに生成したSecret文字列を登録します。
* Cisco Webex Teams APIサービスは、登録されたSecret文字列を使って、
通知するイベントのデータから`HMAC-SHA1`のハッシュ値を計算して、`X-Spark-Signature`ヘッダに追加します。
* Webhook通知先のアプリでは、イベントのデータと、このヘッダ値を取得して、`HMAC-SHA1`のハッシュ値が正しいか確認します。
* ハッシュ値が正しい場合のみ、処理を続けます。

> Secret文字列は、Webhook登録時に、アプリとCisco Webex Teams APIサービスの間でやりとりされる値です。
> この文字列を知らなければ、正しいハッシュ値を生成するのは、ある程度難しくなります。
> トンネリングサービスを利用した例では、
> Secret文字列の交換は、Webhook登録時に、アプリとCisco Webex Teams APIの間で行われるので、
> トンネリングサービス側にSecret文字列は知られることはありません(意図的にそうしない限りにおいては)。

## 1-4. Cisco Webex TeamsのWebhookのプライバシ

メッセージが投稿されると、登録しておいたWebhookのhttpsの通知先にイベントが通知されます。
**このイベントには、投稿されたメッセージのテキストそのものは含まれていません**。
イベントに含まれているのは、メッセージのIDや投稿者のID、アカウント名、投稿時刻などです。

Cisco Webex Teamsは、投稿されたメッセージのテキストや画像データなどは、
慎重に保護するように設計されているので、Cisco Webex Teams APIでもこのような設計になっています。

通知されたメッセージIDを使って、テキストはもう1度、明示的に取得する必要があります。
この操作には、有効なトークンと権限が必要になります。

**今回は、Botアカウントのトークンを利用します。
この場合、以下の条件に合致する場合のみ、テキストを取り出すことができます。**

* Botアカウントのトークンが有効であること。
* かつ、該当のBotがメンバーになっているスペース内のメッセージであること。
* かつ、該当のBotに明示的に`@Mention`で話しかけられたメッセージ、または、Bot自身が投稿したメッセージであること。
(Botとの1:1スペースでは`@Mention`は不要)

> トンネリングサービスを利用した例では、
> トンネリングサービス側にBotのトークンが知られることはありません(意図的にそうしない限りにおいては)。
> メッセージのテキストを取り出せるのは、一般的には、Botのトークンを保有しているアプリケーションのみです。
> よって、トークンの保護はかなり重要になります。

# 2. 実際に実装してみよう

ごちゃごちゃ書きましたが、Webex Teams API Clientを利用すると簡単に実装できます。
Webex Teams API Clientは、簡易版のWebhookのサーバ機能を実装しています(.NET Standard 2.0+, .NET Framework 4.5.2+が必要)。
簡易版のサーバー機能は、さくっと試す場合の利用を意図しているので、実運用のアプリでは、より堅牢なサーバ系のソリューションを活用すべきです。

とりあえず、今回は、
「今何時？」と聞かれたら、時刻を答えるだけのBotを実装してみようと思います。

## 2-1. Webhookの通知先を登録する

`WebhookListener`クラスを使って、Webhookのイベント通知を受け取ることができます。

``` csharp
// WebhookListenerのインスタンスを作成して、通知先のポートを登録する。
var webhookListener = new WebhookListener();
var listeningUri    = webhookListener.AddListenerEndpoint("localhost", 8080, false);
```
`WebhookListener.AddListenerEndpoint()`メソッドの
第１引数は、通知先のアドレス、
第２引数は、通知先のポート番号、
第３引数は、httpsを利用するかどうかのフラグです。

今回は、ngrokのトンネリングサービスを利用するので、
通知先のアドレスはlocalhost、
通知先のポート番号は、ngrokのツール起動時に指定したフォワード先のポート番号を指定します。
ngrokが確保するURLは、httpsのURLですが、フォワード先ではhttpを利用するので、
最後のフラグはfalseにします。

**今回の例では、ngrokを使って、フォワードさせているため、フォワード先のポートは、
ローカルの環境側で保護されていることを前提としてます。
このような前提が成り立たない場合は、必ず、WebhookListener側でもhttpsを利用します。**

> WebhookListenerの通知先としてhttpsを利用する場合は、
> ローカル環境でサーバ証明書とポートをバインドする設定をしておく必要があります。

`WebhookListener.AddListenerEndpoint()`メソッドの戻り値は、
Webhookの通知先のURLを表すUriオブジェクトですが、
今回は、ngrokを使ってトンネリングしているので、
Cisco Webex Teams APIサービスにWebhookを登録する際に補正しておく必要があります。

以下は、ngrokが割り当ててURLが、`https://xyz-ngrok.example.com`の場合の例です。
これは、ngrokツールの起動ごとに変わるので、適時読み替える必要があります。
この時点で、ngrokが起動している必要があります。

``` csharp
// 今回の例では、ngrokを利用するので、Webhookの通知先に補正をかける必要があります。
var webhookUri = new Uri(String.Format("https://xyz-ngrok.example.com{0}", listeningUri.AbsolutePath));

// 補正をかけたUriを使ってWebhookを登録します・
// 登録するWebhookの名前は、削除するときにわかりやすい名前にしておきます。
var resw = await teams.CreateWebhookAsync("#mytestwebhook", webhookUri, EventResource.Message, EventType.Created);
```

`CreateWebhookAsync()`メソッドで、Cisco Webex Teams APIサービスにWebhookを登録しておきます。
使い終わったら削除したいので、第1引数で指定する名前にわかりやすい名前を付けておきましょう。
第2引数は、Cisco Webex Teams APIがイベント発生時に通知する先のURLです。
今回は、ngrokを使用するので、補正済みのURLを指定しています。
第3引数で、メッセージに関するイベントのみ通知することを指定しています。
第４引数では、そのメッセージが投稿されたときのみ通知することを指定しています。

明示的に指定しなければ、
`CreateWebhookAsync()`メソッド内部で、Secret文字列が動的に生成されています。

## 2-2. Webhookの通知された時の処理を実装する

かなり、いいかげんに実装すると、こんな感じです。

``` csharp
if (resw.IsSuccessStatus)
{
    webhookListener.AddNotification(
        resw.Data,
        async (eventData) =>
        {
            Console.WriteLine("イベントが通知されました。 id = {0}", eventData.Id);

            // 通知されたメッセージにはテキスト情報はないので、詳細を取得する
            var resm = await teams.GetMessageAsync(eventData.MessageData);

            if (resm.IsSuccessStatus && resm.Data.Text.Contains("今何時？"))
            {
                // 「今何時？」というテキストを含む場合は、答える。
                await teams.CreateMessageAsync(resm.Data.SpaceId, String.Format("{0}だよ！", DateTime.Now.ToString("HH時mm分ss秒")));
            }
        });

    // WebhookListenerを開始する
    webhookListener.Start();
}

Console.WriteLine("何かキーを押すと終了します");
Console.ReadKey(true);
```

`WebhookListener.AddNotification()`に通知対象の`Webhook`オブジェクトと、
イベント発生時のコールバックメソッドを登録することができます。
第１引数の通知対象の`Webhook`オブジェクトは、`CreateWebhookAsync()`の戻り値から取得できます。
第2引数は、イベントが発生した際に呼び出されるメソッドです。
今回の例では、ラムダ関数になっています。

イベント発生時のコールバックメソッドが呼び出される際には、
第1引数にイベントを表す、`EventData`オブジェクトのインスタンスが渡されます。
今回の例では、メッセージのイベントしかとっていないので、
`EventData.MessageData`で、通知されたメッセージが取得できます。

**イベント通知の時点では、メッセージのテキストは取得できていないので、**
`GetMessageAsync()`で、テキストも含めたメッセージ詳細を取得しています。

これでテキストが取得できるので、「今何時？」を含む場合に、
`CreateMessageAsync()`で、回答しています。
回答先のスペースはユーザからメッセージが投稿されたスペースと同じスペースを第１引数で指定しています。

`WebhookListener.Start()`を呼び出して、WebhookListenerを開始しています。
開始しないと、イベント通知は処理されません。
また、アプリが終了すると通知が受け取れないので、この例では、`Console.ReadKey(true)`してます。

## 2-3. とりあえず、できた

これで、該当のBotがいるスペースで、Botに`@Mention`付き(Botとの1:1スペースでは`@Mention`不要)で、
「今何時？」と投稿すると、回答してくれます。

とりあえずは、できましたが、致命的に近い問題があります。
(「今何時？」を含むかどうかという判断だけなので、この部分は処理がいいかげんすぎますが、
そこは、今回、棚上げしときます。)

このアプリを実行すると、Botへの投稿がイベント通知されますが、
Botが回答した時点でもイベントが通知されているはずです。
Webhookには、Bot自身の投稿も通知されます。

Bot自身の投稿に、Botが回答すると、場合によっては、ループが発生します。
場合によらなくても、Bot自身の投稿に`GetMessageAsync()`でテキスト取得するのは、
無駄になる場合が多いです。

## 2-4. ループや無駄な処理に対処する

基本的には、アプリ起動時に、
`GetMeAsync()`を使って、Bot自身の情報を取得して記憶しておきます。

メッセージ投稿のイベント通知時に、Bot自身の投稿かどうかをチェックします。

先ほどの例にちょっと追加するとこんな感じです。

``` csharp
// Bot自身の情報を取得
var resMe = await teams.GetMeAsync();

if (resMe.IsSuccessStatus)
{
    // Bot自身の情報を覚えておく
    var botInfo = resMe.Data;

    webhookListener.AddNotification(
        resw.Data,
        async (eventData) =>
        {
            Console.WriteLine("イベントが通知されました。 id = {0}", eventData.Id);

            var message = eventData.MessageData;

            // Bot自身の投稿ではない場合のみ回答候補とする。
            if (botInfo.CheckOwnershipStatus(message) == OwnershipStatus.NotHold)
            {
                // 通知されたメッセージにはテキスト情報はないので、詳細を取得する
                var resm = await teams.GetMessageAsync(message);

                if (resm.IsSuccessStatus && resm.Data.Text.Contains("今何時？"))
                {
                    // 「今何時？」というテキストを含む場合は、答える。
                    await teams.CreateMessageAsync(resm.Data.SpaceId, String.Format("{0}だよ！", DateTime.Now.ToString("HH時mm分ss秒")));
                }
            }
            else
            {
                Console.WriteLine("テキスト取得する必要なし");
            }
        });

    // WebhookListenerを開始する
    webhookListener.Start();
}
```

ポイントは、Bot自身の情報を取得して、覚えておくところです。
イベント通知時には、
`CheckOwnershipStatus(message)`の部分で、自分が投稿したメッセージかどうかをチェックしています。
`OwnershipStatus.NotHold`は、**メッセージの所有権を保持していない**という意味です。
メッセージの所有権を持つのは、あくまでも、投稿者本人です。

## 2-5. 終わったらWebhookの登録を解除する

Webhookの通知が必要なくなった時点で、登録を解除しましょう。

`DeleteWebhookAsync()`メソッドで解除できます。
例のコードに追記するとこんな感じです。
(成功したかどうかのチェックは省略。)

``` csharp
Console.WriteLine("何かキーを押すと終了します");
Console.ReadKey(true);

if(resw.IsSuccessStatus)
{
    await teams.DeleteWebhookAsync(resw.Data);
}
```

解除しておかないと、ゴミなWebhookが残ることになります。

アプリが異常終了とかで解除できないケースもあるでしょう。
今回の例では、「"#mytestwebhook"」という名前にしているので、検索して解除も考えられます。
(成功したかどうかのチェックは省略。)

``` csharp
var reslw = await teams.ListWebhooksAsync(max: 50);

if(reslw.IsSuccessStatus && reslw.Data.HasItems)
{
    foreach (var item in reslw.Data.Items)
    {
        if (item.Name.Contains("#mytestwebhook"))
        {
            await teams.DeleteWebhookAsync(item);
        }
    }
}
```

> この例では、ListWebhooksAsync(max: 50)で、最大50件まで取得しています。
> 今回の本題ではないので、省略しますが、
> reslw.HasNextがtrueの場合は、reslw.ListNextAsync()で次の最大50件が取得できます。
> 普通はそんなにたくさん、Webhookは登録しません。