Spotify APIを使ってSlackから音楽を検索できるbotを作る

この記事は[ぷりぷりあぷりけーしょんず Advent Calendar 2019](https://qiita.com/advent-calendar/2019/ppap)の23日目の記事です。 

# はじめに
好きなバンドとかアーティストを共有したいときってありませんか？わたしはあります。
そういったときにSlack上でサクッと検索ができたら幸せだなあと思い、アーティスト名を入力すると、そのアーティストの人気曲Top10を返すSlack botを作りました。

Infrastructure as Code の学習も兼ねて、Lambda + API Gateway はCDKで定義しています。

# 使用技術
Slack Outgoing Webhook
Spotify API
AWS Lambda
Amazon API Gateway
AWS CDK 1.19.0
Node.js 12.8.1

※ CDK, Node の環境構築は完了していることを前提とします。

# アーキテクチャ

アーキテクチャはこのようなイメージです。

![purivisual.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/fae0c403-645e-c4e9-ca97-96939962ff4f.png)


ざっくり説明すると、SlackのOutgoing WebhookからAPI GatewayにPOSTし、受け取った文字列をもとにSpotify APIに検索をかけて、その結果をSlackに返すようになっています。

# ディレクトリ構成

CDKでプロジェクトを作成するので、ディレクトリ構成はこのようになります。

```
├── bin
│   └── 〇〇-cdk.ts
├── lambda
│   ├── node_modeules
│   ├── package.json
│   ├── package-lock.json
│   └── search.js
├── lib
│   └── 〇〇-stack.ts
├── node_modeules
├── README.md
├── package-lock.json
├── package.json
└── tsconfig.json
```

# Spotify APIを使えるようにする

Spotify APIを使用するためには、クライアントアプリケーションを作成して認証を通す必要があります。

下記URLからダッシュボードにサインアップ / ログインすることができます。
https://developer.spotify.com/dashboard/

ダッシュボードにログインができたら、`CREATE AN APP`をクリックしてクライアントアプリを作成します。

<img width="509" alt="スクリーンショット 2019-12-21 14.41.52.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/dce2e535-e93a-ec53-16ac-513a33ef7591.png">

<img width="1191" alt="スクリーンショット 2019-12-21 14.48.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/c9be36c9-5f96-afbd-da94-8e43cf188453.png">

アプリが作成されたら、`Client Id`と`Client Secret`をメモしておきます。

Spotify APIには3種類の認証方法がありますが、今回はクライアント側から直接APIを叩く構成ではないため、`Client Credentials Flow`という認証フローを採用しています。

各認証方法についての詳細はこちらをご参照ください。
https://developer.spotify.com/documentation/general/guides/authorization-guide/


# CDKでAWSリソースを定義していく

AWSリソースと、Lambdaにデプロイするコードを書いていきます。

## Nodeのプログラムを作る

`/lambda`ディレクトリを作成し、そのディレクトリで`npm init`でpackage.jsonを作成したら、

`npm install request`で request のパッケージをインストールしておきます。


```/lambda/search.js
exports.handler = (event, context, callback) => {
    const request = require('request');

    const authOptions = {
        url: 'https://accounts.spotify.com/api/token',
        headers: {
            'Authorization': 'Basic ' + process.env.ACCESS_TOKEN
        },
        form: {
            grant_type: 'client_credentials'
        },
        json: true
    };

    request.post(authOptions, function (error, response, body) {
        if (error) {
            console.log('POST Error: ' + error.message);
            return;
        }

        const token = body.access_token;
        const artist = event.text.slice(5);
        const encoded = encodeURIComponent(artist);

        const options = {
            url: 'https://api.spotify.com/v1/search?q=' + encoded + '&type=artist&market=JP&limit=1&offset=0',
            headers: {
                'Authorization': 'Bearer ' + token
            },
            json: true
        };

        request.get(options, function (error, response, body) {
            let res = {};

            if (error) {
                console.log('GET Error: ' + error.message);
                res.text = '検索に失敗しました。ごめんなさい！';
                callback(null, res);
            } else {
                res.text = body.artists.items[0].external_urls.spotify;
                callback(null, res);
            }
        });
    });
};

```

SlackからPOSTされた文字列は`event.text`で取得することができます。Outgoing Webhookのトリガーとなる文字列を除くため`slice(5)`としています。

あとはその文字列（アーティスト名）をエンコードして、`https://api.spotify.com/v1/search`のクエリパラメータに含めてリクエストをするだけです。

APIの細かい仕様はこちらをご参照ください。

https://developer.spotify.com/documentation/web-api/reference/search/search/

レスポンスが複数の場合もありえますが、今回は1件目だけをSlackに返すようにしています。

## Lambdaを定義する

`npm install @aws-cdk/aws-lambda` でLambdaのライブラリをインストールしたら、`/lib`配下のtsファイルにコードを書いていきます。

```/lib/〇〇-stack.ts
import cdk = require('@aws-cdk/core');
import lambda = require('@aws-cdk/aws-lambda');
import { Duration } from '@aws-cdk/core';

export class PurivisualSearchCdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // lambda
    const search = new lambda.Function(this, 'SearchHandler', {
      runtime: lambda.Runtime.NODEJS_10_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'search.handler',
      timeout: Duration.minutes(1),
      environment: {
        "ACCESS_TOKEN": "< your access token >"
      }
    });
  }
}
```

`environment`の`< your access token >`には、Spotifyでアプリを作成したときに発行した`Client Id`と`Client Secret`をbase64でエンコードした文字列を入れてください。

ターミナルで下記コマンド叩くとエンコードした文字列を出力できます。

```
echo -n < Client Id >:< Client Secret > | base64
```

## API Gatewayを定義する

`npm install @aws-cdk/aws-apigateway` でAPI Gatewayのライブラリをインストールします。

```/lib/〇〇-stack.ts
import cdk = require('@aws-cdk/core');
import lambda = require('@aws-cdk/aws-lambda');
import apigw = require('@aws-cdk/aws-apigateway');
import { Duration } from '@aws-cdk/core';

export class PurivisualSearchCdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    // lambda
    const search = new lambda.Function(this, 'SearchHandler', {
      runtime: lambda.Runtime.NODEJS_10_X,
      code: lambda.Code.fromAsset('lambda'),
      handler: 'search.handler',
      timeout: Duration.minutes(1),
      environment: {
        "ACCESS_TOKEN": "< your accsess token >"
      }
    });

    // api gateway
    const api = new apigw.LambdaRestApi(this, 'PurivisualSearchApi', {
      handler: search,
      proxy: false
    });

    // リソースの作成
    const postResouse = api.root.addResource("serach")

    const responseModel = api.addModel('ResponseModel', {
      contentType: 'application/json',
      modelName: 'ResponseModel',
      schema: {}
    });

    const template: string = '## convert HTML POST data or HTTP GET query string to JSON\n' +
        '\n' +
        '## get the raw post data from the AWS built-in variable and give it a nicer name\n' +
        '#if ($context.httpMethod == "POST")\n' +
        ' #set($rawAPIData = $input.path(\'$\'))\n' +
        '#elseif ($context.httpMethod == "GET")\n' +
        ' #set($rawAPIData = $input.params().querystring)\n' +
        ' #set($rawAPIData = $rawAPIData.toString())\n' +
        ' #set($rawAPIDataLength = $rawAPIData.length() - 1)\n' +
        ' #set($rawAPIData = $rawAPIData.substring(1, $rawAPIDataLength))\n' +
        ' #set($rawAPIData = $rawAPIData.replace(", ", "&"))\n' +
        '#else\n' +
        ' #set($rawAPIData = "")\n' +
        '#end\n' +
        '\n' +
        '## first we get the number of "&" in the string, this tells us if there is more than one key value pair\n' +
        '#set($countAmpersands = $rawAPIData.length() - $rawAPIData.replace("&", "").length())\n' +
        '\n' +
        '## if there are no "&" at all then we have only one key value pair.\n' +
        '## we append an ampersand to the string so that we can tokenise it the same way as multiple kv pairs.\n' +
        '## the "empty" kv pair to the right of the ampersand will be ignored anyway.\n' +
        '#if ($countAmpersands == 0)\n' +
        ' #set($rawPostData = $rawAPIData + "&")\n' +
        '#end\n' +
        '\n' +
        '## now we tokenise using the ampersand(s)\n' +
        '#set($tokenisedAmpersand = $rawAPIData.split("&"))\n' +
        '\n' +
        '## we set up a variable to hold the valid key value pairs\n' +
        '#set($tokenisedEquals = [])\n' +
        '\n' +
        '## now we set up a loop to find the valid key value pairs, which must contain only one "="\n' +
        '#foreach( $kvPair in $tokenisedAmpersand )\n' +
        ' #set($countEquals = $kvPair.length() - $kvPair.replace("=", "").length())\n' +
        ' #if ($countEquals == 1)\n' +
        '  #set($kvTokenised = $kvPair.split("="))\n' +
        '  #if ($kvTokenised[0].length() > 0)\n' +
        '   ## we found a valid key value pair. add it to the list.\n' +
        '   #set($devNull = $tokenisedEquals.add($kvPair))\n' +
        '  #end\n' +
        ' #end\n' +
        '#end\n' +
        '\n' +
        '## next we set up our loop inside the output structure "{" and "}"\n' +
        '{\n' +
        '#foreach( $kvPair in $tokenisedEquals )\n' +
        '  ## finally we output the JSON for this pair and append a comma if this isn\'t the last pair\n' +
        '  #set($kvTokenised = $kvPair.split("="))\n' +
        ' "$util.urlDecode($kvTokenised[0])" : #if($kvTokenised[1].length() > 0)"$util.urlDecode($kvTokenised[1])"#{else}""#end#if( $foreach.hasNext ),#end\n' +
        '#end\n' +
        '}'

    // POSTメソッドの作成
    postResouse.addMethod("POST", new apigw.LambdaIntegration(search, {
      // 統合リクエストの設定
      requestTemplates: {
        'application/x-www-form-urlencoded': template
      },
      // 統合レスポンスの設定
      integrationResponses: [
        {
          statusCode: '200',
          contentHandling: apigw.ContentHandling.CONVERT_TO_TEXT,
          responseTemplates: {
            'application/json': "$input.json('$')"
          }
        }
      ],
      passthroughBehavior: apigw.PassthroughBehavior.WHEN_NO_MATCH,
      proxy: false
    }),
        // メソッドレスポンスの設定
        {
          methodResponses: [
            {
              statusCode: '200',
              responseModels: {
                'application/json': responseModel
              }
            }
          ]
        })
  }
}
```

`apigw.LambdaRestApi()`のhandlerに先ほど定義したLambdaを指定してあげることで、LambdaをバックエンドとしたAPIを作成することができます。

SlackからPOSTされるデータは`application/x-www-form-urlencoded`形式のため、jsonに変換しています。AWSフォーラムで紹介されているマッピングテンプレートをまるっとコピーして使用しています。
https://forums.aws.amazon.com/thread.jspa?messageID=673012&tstart=0#673012

## デプロイ

これでAWSリソースとLambdaにデプロイするプログラムが完成したので、デプロイします。

```
cdk diff
cdk deploy
```

# SlackのOutgoing Webhookを設定する

<img width="1012" alt="スクリーンショット 2019-12-21 17.29.53.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/dded69c5-aab6-faf2-6f1f-d8a23ef9c36a.png">

「引き金となる言葉」には被ることがないような文言を設定しておくのが無難です。わたしは推しの名前にしました。

あとは、URLに作成したAPI Gatewayのエンドポイントを指定し、名前やアイコンなどを設定して保存すればbotの完成です。

<img width="441" alt="スクリーンショット 2019-12-21 12.15.05.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/356412/11c84a87-b71e-7803-a3bf-67aa0e7f3037.png">

このように、「SORA」のあとにスペース＋アーティスト名で、そのアーティストの人気曲Top10が表示されるようになりました！残念ながらSlack上で再生ができるは視聴版のみとなっており、全曲フル尺で再生したい場合はリンクからSpotifyを開く必要があります。

# 最後に
こういうbotがあれば「このアーティストオススメだから聴いてみて！」が簡単にできて楽しいかなと思って作ってみました。
あとbotのアイコンを推しにするとかなり愛着が湧きます！
Spotify APIは他にも色んなエンドポイントが用意されているので、気になる方は是非使ってみてください！