素数大富豪がはかどる素数判定Botをビルドしてみた（Slack 編）

この記事は、社内有志数名による第２回[素数大富豪](http://integers.hatenablog.com/entry/2016/10/10/193619)を数日後に控え、前回使用した素数判定アプリが iOS 11 ではもはや動作しないことが判明したため、素数判定してくれる Bot を（ほかのアプリを探してもよかったのですがせっかくなので）ビルドしたメモです。

## ゴール

- 与えられた数を素数判定できる。
- 判定履歴を残せて共有もできる。

> 完成イメージ

![completed-app-image.png](https://qiita-image-store.s3.amazonaws.com/0/7477/41d809e5-7789-d36f-0b8d-d12b2fc4c783.png)

## スタック

単なる思いつきと多少の事情から今回採用したスタック。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/7477/6b0bd00a-428d-2573-d26d-6ccb61f7606b.png)

- [Google Cloud Platform](https://cloud.google.com/?hl=ja) (以下 GCP)
  - [App Engine](https://cloud.google.com/appengine/?hl=ja)
  - [Cloud Endpoints](https://cloud.google.com/endpoints/?hl=ja)
  - [Cloud Functions](https://cloud.google.com/functions/?hl=ja)
- [Dialogflow](https://dialogflow.com/)
- [Slack](https://slack.com/intl/ja-jp)

## 実装

ここでは、GCP にプロジェクトID `prime-daifugo`が存在する前提で話を進めます。プロジェクトIDは任意ですので、ご自身のプロジェクトIDに読み替えて進めてください。

実装の手順はつぎの通りです。

1. App Engine を作成
2. 素数判定アプリをデプロイ
3. API key を発行
4. Dialogflow にエージェントを作成
5. Cloud Functions を作成
6. Dialogflow と Slack App のインテグレーション

### 1. App Engine を作成

[gcloud](https://cloud.google.com/pubsub/docs/quickstart-cli?hl=ja) コマンドラインツールを使って、ターミナルからつぎのように実行します。

```bash
$ gcloud components update
$ gcloud auth login
$ gcloud config set project prime-daifugo
$ gcloud config list
$ gcloud app create --project=prime-daifugo --region=asia-northeast1
```

素数判定 API バックエンドとして利用するアプリの[サンプルコード](https://github.com/azukiwasher/primality-testing-apps)を用意しました。よろしければ、リポジトリを clone してお使いください。

肝心の素数判定には、[src/math/big/prime.go](https://golang.org/src/math/big/prime.go) の `ProbablyPrime` を使用しています。

この関数のコメントにもあるように、2の64乗（18446744073709551616）より小さい数であれば、100% 正確に素数判定できるようですので、素数大富豪で採用する分には実用上の問題はなさそうです。

```bash
$ git clone https://github.com/azukiwasher/primality-testing-apps
$ cd primality-testing-apps/go
```

`primality-testing-apps/go/openapi-appengine.yaml` をエディタで開いて `YOUR-PROJECT-ID` を "prime-daifugo" に変更します。

```yaml
# [START swagger]
swagger: "2.0"
info:
  description: "A primality test API to determine whether an input number is prime."
  title: "Primality Test API"
  version: "1.0.0"
  host: "YOUR-PROJECT-ID.appspot.com"
# [END swagger]
    
snip...
```

### 2. 素数判定アプリをデプロイ

まず、**Cloud Endpoints** をデプロイします。

```bash
$ gcloud endpoints services deploy openapi-appengine.yaml
$ gcloud endpoints configs list --service=prime-daifugo.appspot.com
CONFIG_ID     SERVICE_NAME
2017-12-11r0  prime-daifugo.appspot.com 
```

`primality-testing-apps/go/app.yaml` をエディタで開いて `YOUR-PROJECT-ID` を "prime-daifugo" に、`CONFIG_ID` を "2017-12-11r0" にそれぞれ置き換えます。 

```yaml
snip...
    
endpoints_api_service:
  # The following values are to be replaced by information from the output of
  # 'gcloud service-management deploy openapi.yaml' command.
  name: YOUR-PROJECT-ID
  config_id: CONFIG_ID

snip...
```

最後に、アプリをデプロイします。

```bash
$ gcloud app deploy
```

### 3. API key を発行

GCP コンソールの左メニューから **APIs & Services** をクリックして、API key を発行します。**Cloud Functions** から **Cloud Endpoints** へリクエストする際の認証トークンとして使用します。

![publish-api-key.png](https://qiita-image-store.s3.amazonaws.com/0/7477/cec14372-7032-b229-a3d4-9c8e2f71ad53.png)

![credentials.png](https://qiita-image-store.s3.amazonaws.com/0/7477/64e87f86-d020-a236-e076-197c819ddd4c.png)

App Engine へのアプリのデプロイと API key の発行が済んだので、API バックエンドの動作をテストしてみましょう。

`ENDPOINTS_KEY` にさきほど発行した API Key を設定します。 `ENDPOINTS_HOST` には "prime-daifugo.appspot.com" を設定します。

```bash
$ export ENDPOINTS_KEY=YOUR-API-KEY
$ export ENDPOINTS_HOST=https://YOUR-PROJECT-ID.appspot.com
$ curl -X GET -H "content-type:application/json" "${ENDPOINTS_HOST}/primes/1213?key=${ENDPOINTS_KEY}"
{"number":1213,"is_prime":true}
$ curl -X GET -H "content-type:application/json" "${ENDPOINTS_HOST}/primes/1729?key=${ENDPOINTS_KEY}"
{"number":1729,"is_prime":false}
```

### 4. Dialogflow にエージェントを作成

Dialogflow コンソールにログインして、エージェントを作成します。

![agent.png](https://qiita-image-store.s3.amazonaws.com/0/7477/197b6ca9-5e19-9e52-72ee-7ff91ee190c9.png)

エージェントの属性は、たとえばつぎのような感じで設定します。入力が済んだら **CREATE** をクリックします。

| Key               | Value                 |
| ----------------- | --------------------- |
| Agent name        | Rachael               |
| DEFAULT LANGUAGE  | Japanese - ja         |
| DEFAULT TIME ZONE | (GMT+9:00) Asia/Tokyo |
| GOOGLE PROJECT    | prime-daifugo         |


エージェントを作成したら、**Intents** を登録していきます。**Intents** は、ユーザから送信されるメッセージとエージェントのアクションとのマッピングを表現します。

![intents.png](https://qiita-image-store.s3.amazonaws.com/0/7477/32ee1b5c-a19b-5ff1-3016-eb00d24a3d6a.png)

**CREATE INTENT** をクリックして、つぎのように **Intents** を入力します。

| Key         | Value       |
| ----------- | ----------- |
| Intent name | 素数判定     |
| User says   | 2は素数ですか？ |

**User says** に「2は素数ですか」と入力します。「2」の上にイエローのマーカーが掛かるはずです。そこをクリックするとオプションが表示されるので、下にスクロールして **@sys.number** を選択します。

この **@sys.number** は、**Entities** と呼ばれるディクショナリに含まれる定義のひとつで、ユーザから送られるメッセージから特定の値（parameter values）を抜き出すために参照されるものです。抜き出された値は、のちほどふれる **Cloud Functions** へ渡す引数として利用できます。

![user-says.png](https://qiita-image-store.s3.amazonaws.com/0/7477/e91260b5-103b-f4f6-3cad-7056adbf3560.png)

**Action**セクションにレコードがひとつ追加されます。左端にある**REQUIRED**をチェックします。

![action.png](https://qiita-image-store.s3.amazonaws.com/0/7477/a31de4f9-aee8-0e76-3579-2a3942ea88d7.png)

右端に **PROMPTS** 列が現れます。**Define prompts...** をクリックして、つぎのような感じでユーザからのメッセージに数字が含まれていない場合の返答を用意しておきます。

![prompts.png](https://qiita-image-store.s3.amazonaws.com/0/7477/266d10af-a2c6-261c-f4e8-c1ce1f03a089.png)

テストします。右上にある **Try it now** から数字を含む適当なセンテンスを入力してみます。 **INTENT** が「素数判定」、**VALUE** が「1213」となればOKです。 いまのところ、**DEFAULT RESPONSE** は *Not available* で問題ありません。 

![test-intent.png](https://qiita-image-store.s3.amazonaws.com/0/7477/8c7c4388-6536-ad67-7c4c-503174a9232b.png)

最後に、**SAVE** をクリックします。
つぎから、GCP の **Cloud Fucntions** と Dialogflow の **Fulfillment** を利用してレスポンスを返すように仕込んでいきます。


### 5. Cloud Functions を作成

GCP コンソールに戻り、**Cloud Functions** メニューをクリックします。つぎのダイアログが表示されたら、**Create function** をクリックします。

![create-cloud-functions.png](https://qiita-image-store.s3.amazonaws.com/0/7477/8a869d09-1868-4605-fc61-b7a3dd53c575.png)

各フィールドに値を入力していきます。

![image.png](https://qiita-image-store.s3.amazonaws.com/0/7477/bda2ae47-a46b-a5b2-e0e7-7afed6a27455.png)

たとえばこんな感じです。

| Key                  | Value                      |
| -------------------- | -------------------------- |
| Name                 | primality-test             |
| Memory allocated     | 128 MB                     |
| Trigger              | HTTP Trigger               |
| Source code          | Inline editor              |
| index.js             | ＊以下に示すソースコード          |
| Storage bucket       | prime-daifugo.appspot.com/ |
| Function to execture | primalityTestWebhook       |


**index.js** にはつぎのコードを入力します。`YOUR-HOST` は "prime-daifugo.appspot.com" にします。 `YOUR-API-KEY` は [**4. API key の発行**](https://qiita.com/azkiwasher/items/cd4e865186c40816b3d3#3-api-key-%E3%81%AE%E7%99%BA%E8%A1%8C) で発行した Key を設定します。

```javascript
/*
* HTTP Cloud Function.
*
* @param {Object} req Cloud Function request context.
* @param {Object} res Cloud Function response context.
*/

'use strict';
const https = require('https');
const host = 'YOUR-HOST';
const apiKey = 'YOUR-API-KEY';

exports.primalityTestWebhook = (req, res) => {
  let number = req.body.result.parameters['number'];
  callPrimalityTestApi(number).then((output) => {
    // Return the results of the primality test API to Dialogflow
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({ 'speech': output, 'displayText': output }));
  }).catch((error) => {
    // If there is an error let the user know
    res.setHeader('Content-Type', 'application/json');
    res.send(JSON.stringify({ 'speech': error, 'displayText': error }));
  }); 
};

function callPrimalityTestApi (number) {
  return new Promise((resolve, reject) => {
    let path = '/primes/' + number + '?key=' + apiKey;
    console.log('API Request: ' + host + path);
    // Make the HTTP request to get the result of test
    https.get({host: host, path: path}, (res) => {
      let body = ''; // var to store the response chunks
      let message = ''; 
      res.on('data', (d) => { body += d; }); // store each response chunk
      res.on('end', () => {
        // After all the data has been received parse the JSON for desired data
        let response = JSON.parse(body);
        let output = response['is_prime'];
        console.log(output);
        if (output) {
          message = "素数ね。";
        } else {
          message = "素数じゃないわ。";
        }
        resolve(message);
      });
      res.on('error', (error) => {
        reject(error);
      });
    });
  });
}
```

ポイントとしては、`let number = req.body.result.parameters['number']` で、さきほど Dialogflow の **Intents** で設定した **@sys.number** で抜き出される値、つまり数値が渡されています。

入力がすべて完了したら、**Create** をクリックします。**Overview** から "primality-test" をクリックして、**Trigger** メニューを選択します。

![cloud-functions-trigger.png](https://qiita-image-store.s3.amazonaws.com/0/7477/9958e9f0-558b-82cd-d3aa-4509c5a15f61.png)

**URL** に表示されているアドレス `https://us-central1-prime-daifugo.cloudfunctions.net/primality-test` を控えておきます。Dialogflow の **Fulfillment** の設定で必要になります。

![functions-url.png](https://qiita-image-store.s3.amazonaws.com/0/7477/10da5386-c425-475c-32af-15e81b61d02f.png)

Dialogflow コンソールに戻り、**Fulfillment** を選択します。**Fulfillment** は、ユーザから送られるメッセージにマッチした Intent（素数判定）から値（@sys.number:number）を受け取り、webサービス（Cloud Functions）を呼び出してその結果を返してくれる、いわゆる webhook を実現するための仕組みです。

**Webhook** を `ENABLED` にして、Cloud Functions で控えた URL を **URL*** に入力し、**SAVE** をクリックします。

![fulfillment-url.png](https://qiita-image-store.s3.amazonaws.com/0/7477/26ffe809-fe60-c069-8079-b2326c230451.png)

**Intents** の「素数判定」から、画面中央下にある **Fulfillment** をクリックして **Use Webhook** をチェックします。最後に **SAVE** をクリックします。

![fulfillment-webhook.png](https://qiita-image-store.s3.amazonaws.com/0/7477/3abdaf27-dabd-f0c5-0e67-1ac13174d974.png)

Webhook の動作をテストしておきましょう。**Try it now** にセンテンスを入力して、**DEFAULT RESPONSE** にメッセージが返えされれば、OKです。

![test-webhook.png](https://qiita-image-store.s3.amazonaws.com/0/7477/d403c708-2219-dd45-e1b0-884cc4803739.png)

### 6. Dialogflow と Slack App のインテグレーション

Slack とインテグレーションするためのとてもわかりやすい[ガイド](https://dialogflow.com/docs/integrations/slack)が用意されています。これにしたがって **Slack App** をセットアップして、ここで作成した Dialogflow エージェントとインテグレーションしてみてください。

うまくインテグレーションできれば、こんな感じのやりとりが Slack 上で実現できるはずです。

![rachael-primality-testing-bot.gif](https://qiita-image-store.s3.amazonaws.com/0/7477/9ecf74a4-2f47-3e63-ec15-81c22a4c2ddc.gif)

## まとめ

### 実戦投入してみた感想

実際に素数大富豪で使用してみると、確かに、判定履歴は次回以降に向けた傾向と対策を模索するのに有益な情報なのですが、合成数出しをはじめ、`2J`、`QK` といったメジャーどころはわざわざ判定にかけないので対戦記録（棋譜）としては不十分なものになってしまいました。

当初は、Slack に専用チャネルをひとつ作って、プレーヤーが自分の出した札を自分のスマホから Bot に判定させれば、だれがいつ何を出したのか履歴も勝手に残るので効率的だと考えていましたが、逆に、過去に出された素数も含めてお互いがシェアしてしまうと、あらたな素数との出会いが少なくなるといった懸念もあり、そのような運用は今回は見送りました。

### 今後の課題

判定した数が素数でなかった場合は、素因数分解の結果を返してあげるとプレーヤーの納得感が高まりそうです。また、すべて数字ではなく、`J` `Q` `K` を直接入力値に含められると（Joker を含む場合はむつかしそうですが）、出したカードの枚数も分かってよさそうです。

今回のような使い方では、素数判定した結果がプレイヤー全員に見えるようスマホを一台、テーブルの上に置くことになり、毎回毎回前屈みになってスマホに向かって音声入力（またはタイプ）しなければならず少々ツラかったです。

Dialogflow であれば、[Google Assistant とのインテグレーション](https://dialogflow.com/docs/integrations/google-assistant)も可能なので、Google Home を導入すれば、履歴は履歴で残しておき、グラス片手にエレガントな姿勢のまま、これまでにない腰痛フリーなゲーム進行が可能になるかもしれません。

また、場所柄差し支えなければ、各プレイヤーのターンで残り時間をカウントしたり、雰囲気を演出したければ [Spotify](https://www.spotify.com/jp/googlehome/) から素敵なサウンドを流しておき、小腹が減ったらピザを注文（まだ無理？）するなど、素数大富豪における Google Home のポテンシャルは案外高そうです。

最後に、蛇足ですが、App Engine に API バックエンドをたてるのではなく、Cloud Functions の `index.js`で素数判定してしまえば、App Engine の費用が浮きます。今回は、Go で素数判定する方法を調べたかったのであえてこのようなスタックになってしまいました。ふだんは、App Engine インスタンスを Disabled にしておき、素数大富豪を楽しむときだけ、App Engine > Settings から **Enable application** しましょう。

以上です。最後まで読んでいただきありがとうございます。