Nuxt.jsで作るLINEボット

## はじめに

LINEBot&Clova Advent Calendar 2018 13日目の記事は[@nori3tsu](https://twitter.com/nori3tsu)が担当します！

これまで様々な構成でLINEボットを開発する機会がありました。その中でNuxt.jsを使った開発が一番開発効率が良かったのでご紹介します。

最近作った構成例:

* AWS API Gateway + Lambda(Node.js) + DynamoDB
* Ruby on Rails + Sidekiq
* Node.js + Express + [Bull](https://github.com/OptimalBits/bull)
* Nuxt.js(Vue.js + Express)

※[【東京】LINE Bot & Clova CEK開発者2018大忘年会 \- connpass](https://line.connpass.com/event/109192/)の[LT](https://speakerdeck.com/nori3tsu/nuxt-dot-jsdezuo-rulinebotuto)で発表した内容の詳細でもあります。

## Nuxt.jsとは

[Nuxt.js](https://ja.nuxtjs.org/guide/)とは[Vue.js](https://jp.vuejs.org/index.html)アプリを作成するためのフレームワークです。すぐにフロントエンド開発を始められる機能がそろっていることが特徴で、今年はとても流行っていたという印象があります。

* シングルページアプリケーション(SPA)
* サーバーサイドレンダリング(SSR)
* ルーティング(Vue-Router)
* AltJS, AltCSSのトランスパイレーション
* HTTPサーバー(Node.js, Express, Koa, Hapi 等)
    * 静的ファイルの配信
    * サーバサイドレンダリング

## なぜNuxt.jsでLINEボット？

### 1. Nuxt.jsですぐにLINEボット開発をはじめられる

Nuxt.jsはプロジェクトを作成するだけで開発に必要な様々な機能を備えたプロジェクトテンプレートが出来上がるので、すぐに開発を始めることができます。また、Vue.jsによるフロントエンド開発の機能に加えHTTPサーバの機能を備えているため、Messaging APIで利用する静的ファイル(画像・動画・音声等)の配信や、Webhook用のAPIを実装をすることも可能です。

### 2. Messaging APIでメッセージングキューが不要

LINE Messaging APIから送信されるWebhookリクエストには下記の２つの特徴があります。

1. 1リクエストに複数のイベントが入ってくる（一説によると最大数百イベント）
2. Webhookリクエストはなるべく速くHTTPステータス200を返却する必要がある（一説によると1秒）

これらの特徴からWebhookサーバーは基本的にメッセージングキューと組み合わせて実装することになりますが、Nuxt.jsに含まれるNode.jsはイベントループ・非同期IOで動作するため、リクエストに含まれる各イベントをイベントループに登録してすぐにレスポンスを返却すれば、ユーザへのリプライ送信等の処理は非同期で実行させることができます。

ある程度規模が大きくなったりCPUを使う処理が多く含まれている場合はメッセージングキューとワーカーを組み合わせる必要も出てきますが、Node.jsのみで問題ないケースも多いと思います。

### 3. SPAでLIFFを高速化できる

LIFFは、LIFF SDKを利用するために画面表示にSDKを初期化します。この初期化処理は、おそらく裏側でアクセストークンを取得するなどの処理が入っており、体感速度的に結構遅いです。LIFFアプリ上の複数の画面でSDKを利用する場合、都度初期化処理が実行されることになります。

Nuxt.jsはVue.jsアプリケーションを作るためのフレームワークであるため、Vue.jsを使ったSPAを実装することができます。
SPAだとLIFF起動時に一度だけSDKの初期化が実行すれば良いので、この影響を小さくすることができます。

## 実装の詳解

実際にNuxt.jsを使ってMessaging APIのWebhookとLIFFを実装していきます。

ここで実装するサンプルプロジェクトは[GitHub](https://github.com/nori3tsu/line-bot-with-nuxtjs)にUPしていて、各章の区切り毎にコミットしているので、是非参考にしてみてください。

## バージョン情報

本稿ではMacの以下のバージョンを利用してアプリケーションを作成しています。

```
$ node -v
v10.13.0

$ npm -v
6.4.1

$ yarn -v
1.12.3

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.14.1
BuildVersion:   18B75
```

## Nuxt.jsプロジェクトの作成

アプリケーションの雛形となるNuxt.jsプロジェクトを作成します。

### プロジェクトの作成

Nuxt.jsプロジェクトを作成するためのcreate-nuxt-appコマンドをインストールしてください。

```
$ npm i -g create-nuxt-app
```

Nuxt.jsプロジェクトを作ります。実行後、先頭が `?` になっている行で入力待ち受けになりますので、下記の通りに設定してください。

```
$ create-nuxt-app
> Generating Nuxt.js project in /Users/Nori/Project/line-bot-with-nuxtjs
? Project name line-bot-with-nuxtjs
? Project description My polished Nuxt.js project
? Use a custom server framework express
? Use a custom UI framework bulma
? Choose rendering mode Single Page App
? Use axios module no
? Use eslint yes
? Use prettier yes
? Author name [お好きに, そのままでもOK。]
? Choose a package manager yarn
```

デフォルトで作成されるプロジェクトにはESLintで警告が発生するコードが含まれていますので、下記のコマンドを実行して自動修正してください。

```
$ npx eslint --fix '**/*.js'
$ npx eslint --fix '**/*.vue'
```

### 開発用サーバの起動

開発用のサーバーを起動します。

```
$ yarn run dev

> line-bot-with-nuxtjs@1.0.0 dev /Users/Nori/Project/line-bot-with-nuxtjs
> cross-env NODE_ENV=development nodemon server/index.js --watch server

[nodemon] 1.18.7
[nodemon] to restart at any time, enter `rs`
[nodemon] watching: /Users/Nori/Project/line-bot-with-nuxtjs/server/**/*
[nodemon] starting `node server/index.js`
ℹ Preparing project for development                                                                                                                                                                                          19:48:25
ℹ Initial build may take a while                                                                                                                                                                                             19:48:25
✔ Builder initialized                                                                                                                                                                                                        19:48:25
✔ Nuxt files generated                                                                                                                                                                                                       19:48:25

✔ Client
  Compiled successfully in 5.06s

ℹ Waiting for file changes                                                                                                                                                                                                   19:48:31

 READY  Server listening on http://127.0.0.1:3000
```

起動後にブラウザから[http://127.0.0.1:3000](http://127.0.0.1:3000)にアクセスして、下記の画面が表示されれば成功です。

![nuxt起動.png](https://qiita-image-store.s3.amazonaws.com/0/5774/a27c2a87-bf5a-8c71-21ab-91254277cbf9.png)


## LINE Messaging APIを実装

Nuxt.jsのExpressを利用してLINE Messaging APIのWebhook機能を実装していきます。

### ExpressにAPI機能を追加

API(`server/api`)とエンドポイント(`server/api/routes`)のディレクトリを作成。

```
$ mkdir -p server/api/routes
```

APIのエンドポイントを読み込むファイルを追加。

```[新規作成]server/api/index.js
const express = require('express')
const app = express()
const router = express.Router()

// まずはルーティングのみ追加
router.post('/webhook', require('./routes/webhook'))

module.exports = router
```

Webhook APIのエンドポイントを追加。

```[新規作成]server/api/routes/webhook.js
module.exports = async (req, res, next) => {
  res.json({ result: 'OK' })
}
```

`(1)開始` から `(1)終了` までを追加。

```[編集]server/index.js
async function start() {
  // Init Nuxt.js
  const nuxt = new Nuxt(config)

  // Build only in dev mode
  if (config.dev) {
    const builder = new Builder(nuxt)
    await builder.build()
  }

  // ===(1)開始===
  // API機能を追加
  app.use(require('./api'))
  // ===(1)終了===

  // Give nuxt middleware to express
  app.use(nuxt.render)

  // Listen the server
  app.listen(port, host)
  consola.ready({
    message: `Server listening on http://${host}:${port}`,
    badge: true
  })
}
```

下記のcurlコマンドを実行して、`{"result":"OK"}` が返却されれば成功です。

```
$ curl -XPOST http://localhost:3000/webhook
{"result":"OK"}%
```

### ngrokのインストール・起動

ローカルでLINEボットを開発するために、`ngrok`をインストールして起動しておきます。
LINEのWebhookを受信するためにはhttps通信が必須です。`ngrok`を利用すると、`ngrok`のhttpsサーバを経由してローカルにリクエストが届きます。

```
$ brew install ngrok
```

`ngrok`を起動します。Forwardingのhttpsから始まるURL(下記の例では`https://1ba069ea.ngrok.io`)にリクエストすると`localhost:3000`にリクエストが届きます。

```
$ ngrok http 3000

ngrok by @inconshreveable                                                                                                                                   (Ctrl+C to quit)

Session Status                online
Account                       Norimitsu Yamashita (Plan: Free)
Version                       2.2.8
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://1ba069ea.ngrok.io -> localhost:3000
Forwarding                    https://1ba069ea.ngrok.io -> localhost:3000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              21      0       0.00    0.00    4.50    29.36
```

下記のcurlコマンドを実行して、`{"result":"OK"}` が返却されれば成功です。

```
$ curl -XPOST https://1ba069ea.ngrok.io/webhook
{"result":"OK"}%
```

※ httpsのURLは`ngrok`を起動する度に変わります。

### LINEチャンネルの作成

LINE Messaging APIのチャンネルをフリープランで作成します。様々な記事で共有されているので詳細な作成方法は割愛しますね。例えば、下記の記事などはわかりやすいと思います。

[【第1回】Messaging APIを使うためにチャンネルを作成する – cmblog](https://masatoshihanai.com/php-line-bot-01/)

### LINEチャンネル設定

作成したチャンネルを選択してチャンネル設定をします。

チャンネル基本設定のタブを開いてください。（チャンネルを開いた直後はこの画面になっているはずです。）

* Webhook送信: `利用する` に変更
* WebhookURL: Herokuで確認したURL+`/webhook`に変更。`ngrok`でForwardingしているhttpsのURLを指定します。（例: `https://1ba069ea.ngrok.io/webhook`
* 自動応答メッセージ: `利用しない` に変更

Webhook URLの`接続確認`ボタンを押して`成功しました`のメッセージが表示されると成功です。

<img width="1088" alt="Webhook接続確認.png" src="https://qiita-image-store.s3.amazonaws.com/0/5774/4acc4904-7c5d-a7bb-cff6-98c147212643.png">


### Webhookエンドポイントの実装を追加

LINE Messaging API用の Webhookエンドポイントを実装していきます。

### 依存ライブラリのインストール

実装に必要なライブラリをインストールします。

```
$ yarn add pino \
           bluebird \
           dotenv \
           request \
           request-promise \
           querystring \
           node-cache \
           @line/bot-sdk           
```

* pino ... ロガー
* bluebird ... Promise拡張
* dotenv ... ファイル(`.env`)から環境変数を読み込む
* request ... HTTPクライアント
* request-promise ... requestのPromise拡張
* querystring ... `application/x-www-form-urlencoded `のエンコード用
* node-cache ... メモリキャッシュ, アクセストークンのキャッシュ用
* @line/bot-sdk ... LINE SDK

### LINEチャンネルアクセストークンの取得・キャッシュ実装を追加

LINEチャンネルアクセストークンの取得・キャッシュ処理を実装します。

```[新規作成]server/api/repositories/line/getChannelAccessToken.js
const logger = require('pino')()
const querystring = require('querystring')
const rp = require('request-promise')
const Promise = require('bluebird')
const NodeCache = require('node-cache')
const cache = require('../cache')

// アクセストークンキャッシュ有効期限
// 60*60*24*29 = 2505600 = 29日間
const ttl = 2505600

module.exports = async (client_id, client_secret) => {
  // キャッシュを取得
  const cacheKey = `access_token:${client_id}`
  const accessToken = await cache.get(cacheKey)
  if (accessToken) {
    logger.info('アクセストークンをキャシュから取得', client_id)
    return accessToken
  }

  // アクセストークンを発行
  logger.info('アクセストークンを発行', client_id)
  const options = {
    method: 'POST',
    uri: 'https://api.line.me/v2/oauth/accessToken',
    body: querystring.stringify({
      grant_type: 'client_credentials',
      client_id,
      client_secret
    }),
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    resolveWithFullResponse: true,
    json: true
  }

  const res = await rp(options)
  if (res.statusCode != 200) {
    return Promise.reject(
      new Error('LINEチャンネルアクセストークンの取得に失敗')
    )
  }

  // キャッシュを設定
  await cache.set(cacheKey, res.body.access_token, ttl)

  return res.body.access_token
}
```

キャッシュ処理を実装します。

```[新規作成]server/api/repositories/cache.js
const NodeCache = require('node-cache')

module.exports = new NodeCache({ stdTTL: 600 })
```

### LINEクライアント生成処理を実装

チャンネルアクセストークンを設定したLINEクライアントを生成する処理を追加します。

```[新規作成]server/api/repositories/line/createLineClient.js
const line = require('@line/bot-sdk')
const getChannelAccessToken = require('./getChannelAccessToken')

module.exports = async () => {
  const channelAccessToken = await getChannelAccessToken(
    process.env.LINE_CHANNEL_ID,
    process.env.LINE_CHANNEL_SECRET
  )

  return new line.Client({ channelAccessToken })
}
```

### Webhookエンドポイントの実装

WebhookエンドポイントにLINEから送信されるJSONボディのパースに対応します。LINEから送信されたリクエストボディのローデータを元に署名検証する必要があるため、JSONにパースせずにテキストのまま受け取ります。

```[編集]server/api/index.js
const express = require('express')
const app = express()
const router = express.Router()
const bodyParser = require('body-parser')

// Webhookエンドポイント
router.post(
  '/webhook',
  // 署名検証のためテキストでパース
  bodyParser.text({ type: 'application/json' }),
  require('./routes/webhook')
)

module.exports = router
```

Webhookエンドポイントの実装を追加します。

```[編集]server/api/routes/webhook.js
require('dotenv').config()

const logger = require('pino')()
const crypto = require('crypto')
const cache = require('../repositories/cache')
const createLineClient = require('../repositories/line/createLineClient')

// LINEの署名検証
const validateSignature = req => {
  if (!req.body) {
    logger.info('LINEの署名検証でリクエストボディが空')
    return false
  }

  try {
    const signature = crypto
      .createHmac('SHA256', process.env.LINE_CHANNEL_SECRET)
      .update(req.body)
      .digest('base64')
    const headersSignature = req.headers['x-line-signature']
    if (headersSignature != signature) {
      logger.info('LINEの署名検証で署名不一致', { headersSignature, signature })
      return false
    }
  } catch (err) {
    logger.info('LINEの署名検証でエラー発生', { msg: err.message })
    return false
  }

  return true
}

module.exports = async (req, res, next) => {
  logger.info({ headers: req.headers, body: req.body })

  // LINEの署名検証
  if (!validateSignature(req)) {
    return res.status(403).json({})
  }

  // LINE Messaging APIのクライントを作成
  const client = await createLineClient()

  const events = JSON.parse(req.body).events
  events.forEach(async event => {
    let messages = [
      {
        type: 'text',
        text: `${event.message.text}を受信しました`
      }
    ]

    // リプライを送信
    // あえてawaitで待ち受けない
    client.replyMessage(event.replyToken, messages).catch(err => {
      logger.error('LINEのリプライに失敗', { msg: err.message })
    })
  })

  // リプライより前にHTTPステータス200を返却
  res.json()
}
```

LINEチャンネル接続情報を設定します。

```[新規作成].env
# チャンネル基本設定の`Channel ID`の値を設定
LINE_CHANNEL_ID=xxx
# チャンネル基本設定の`Channel Secret`の値を設定
LINE_CHANNEL_SECRET=xxx
```

※`.env`はgit管理しません。

`.env`の設定後、Nuxt.jsを再起動します。

再起動完了したら動作確認をします。Webhookを設定したLINEチャンネルに文字を入力、`{入力文字}を受信しました`とリプライが返ってきたら成功です。

<img width="375" alt="Webhook動作確認.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/5774/c7c59ea3-80a3-2fca-3915-5b5040611f3a.png">

## LINE Front-end Framework(LIFF)の実装

Nuxt.jsのExpressとVue.jsを利用したLIFFを実装していきます。

### LIFFアプリの追加

チャンネルを開き、`LIFF`のタブを開いてください。`追加`ボタンを押して下記の内容を設定してください。

* 名前: `LIFFとNuxt.js` (任意)
* サイズ: Compact
* エンドポイントURL: `https://1ba069ea.ngrok.io`
    * `ngrok`で起動したhttpsのURLを指定してください。


LIFFの動作確認用にWebhookのリプライ処理を変更します。`(1)開始` から `(1)終了` まで追加してください。

```[編集]server/api/routes/webhook.js
    let messages = [
      {
        type: 'text',
        text: `${event.message.text}を受信しました`
      }
      // ===(1)開始===
      ,
      {
        type: 'text',
        text: `LIFF動作確認用URL: ${process.env.LIFF_URL}`
      }
      // ===(1)終了===
    ]
```

LIFF URLの環境変数を追加します。`(1)開始` から `(1)終了` まで追加して、Nuxt.jsを再起動してください。

```[編集].env
# チャンネル基本設定の`Channel ID`の値を設定
LINE_CHANNEL_ID=xxx
# チャンネル基本設定の`Channel Secret`の値を設定
LINE_CHANNEL_SECRET=xxx
# ===(1)開始===
# LIFF URLの値を設定
LIFF_URL=line://app/1629132446-jD4868kr
# ===(1)終了===
```
    
LINEチャンネルに文字を入力してください。`{入力文字}を受信しました`と共に`LIFF動作確認用URL: {LIFF URL}`が表示されれば成功です。

<img width="375" alt="LIFF動作確認用URL返信.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/5774/05dc9029-a30a-ab26-4924-7fae4c219b9d.png">


表示されたURLを押すと `Nuxt.jsで作るLINEアプリ` の認可画面が表示されます。ここでは`同意する`を押します。

<img width="375" alt="LIFF認可.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/5774/fb62dbd0-3b7b-01ed-3574-f51c78966f5c.png">


Nuxt.jsのデフォルトページが表示されれば成功です。

<img width="375" alt="LIFF起動.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/5774/e0e4c303-79d7-fc25-4b30-95017a5e5f70.png">

### LIFF SDKの組み込み

Nuxt.jsにLIFF SDKのjsファイルを組み込みます。Nuxt.jsでは、`nuxt.config.js`ファイルの`head`に読み込むjs/cssを指定すると、全ての`*.vue`で読み込んだリソースを利用することができます。

`(1)開始` から `(1)終了` までを追加します。

```[編集]nuxt.config.js
  head: {
    title: pkg.name,
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' },
      { hid: 'description', name: 'description', content: pkg.description }
    ],
    // ===(1)開始===
    script: [{ src: 'https://d.line-scdn.net/liff/1.0/sdk.js' }],
    // ===(1)終了===
    link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }]
  },
```

### LIFFの初期表示画面の実装

デフォルトのCSSを削除します。

```[編集]layouts/default.vue
<template>
  <div>
    <nuxt/>
  </div>
</template>

<style>
</style>
```

LIFFの初期化と、次の項で実装するメッセージ送信・プロフィール取得のためのリンクを設定します。

```[編集]pages/index.vue
<template>
  <div class="container has-text-centered">
    <div class="column is-4 is-offset-4">
      <p class="subtitle has-text-grey">
        Nuxt.jsで作るLINEアプリ[LIFF]
      </p>
      <ul>
        <li>
          <p class="subtitle">
            <nuxt-link to="/send_message">
              メッセージ送信
            </nuxt-link>
          </p>
        </li>
        <li>
          <p class="subtitle">
            <nuxt-link to="/show_profile">
              プロフィール表示
            </nuxt-link>
          </p>
        </li>
      </ul>
    </div>
  </div>
</template>

<script>
export default {
  mounted() {
    // LIFFの初期化
    liff.init(function(data) {
      console.log(data)
    })
  },
}
</script>
```

### LIFFからのメッセージ送信機能の実装

LIFFのSDKを経由して、LIFFアプリを開いているトークルーム上にメッセージを送信する機能を実装します。

```[新規作成]pages/send_message.vue
<template>
  <div class="container has-text-centered">
    <div class="column is-4 is-offset-4">
      <p class="subtitle has-text-grey">
        LINEメッセージングAPI
      </p>
      <div>
        <textarea
          class="textarea"
          placeholder="トークルームに送信する内容"
          v-model="message"
        />
        <br>
        <button
          class="button is-info is-block is-large is-fullwidth"
          @click="send()"
        >
          送信
        </button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      message: ''
    }
  },
  methods: {
    send() {
      if(this.message == '') return;

      liff
        .sendMessages([
          {
            type: 'text',
            text: this.message
          }
        ])
        .then(function() {
          liff.closeWindow()
        })
        .catch(function(error) {
          liff.closeWindow()
        })
      }
  }
}
</script>
```

LIFF動作確認用URLを開いてLINEメッセージングAPIのページに遷移します。テキストを送信して、トークルーム上に入力したテキストが表示されていれば成功です。

<img width="375" alt="SendMessage.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/5774/3465d010-eff0-fdee-d323-a309675a2a19.png">

<img width="375" alt="SendMessage2.PNG" src="https://qiita-image-store.s3.amazonaws.com/0/5774/6ae45eb2-6cee-b14e-6389-84bd919c2928.png">


### LIFFからのプロフィール表示機能の実装

LIFFのSDKを経由して、LIFFアプリを開いているユーザのプロフィール情報を表示する処理を実装します。

```[新規作成]pages/show_profile.vue
<template>
  <div class="container has-text-centered">
    <div>
      <p class="subtitle has-text-grey">
        LINEプロフィールAPI
      </p>
    </div>
    <div class="card">
      <div class="card-content">
        <div class="media">
          <div class="media-left">
            <figure class="image is-48x48">
              <img v-bind:src="pictureUrl">
            </figure>
          </div>
          <div class="media-content">
            <p class="title is-4">{{ displayName }}</p>
            <p class="subtitle is-6">@{{ userId }}</p>
          </div>
        </div>

        <div class="content">
          {{ statusMessage }}
        </div>
      </div>
    </div>

    <div class="column is-4 is-offset-4">
      <div>
        <button
          class="button is-info is-block is-large is-fullwidth"
          @click="getProfile()"
        >
          取得
        </button>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      userId: '',
      displayName: '',
      pictureUrl: 'https://bulma.io/images/placeholders/128x128.png',
      statusMessage: ''
    }
  },
  methods: {
    getProfile() {
      liff.getProfile().then((profile) => {
        this.userId = profile.userId
        this.displayName = profile.displayName
        this.pictureUrl = profile.pictureUrl
        this.statusMessage = profile.statusMessage
      }).catch(function (error) {
        alert("Error getting profile: " + error);
      });
    }
  }
}
</script>
```

LIFF動作確認用URLを開いてプロフィール表示ページに遷移します。取得を押して、LIFFを開いたユーザのプロフィールが表示されていれば成功です。

![ShowProfile.PNG](https://qiita-image-store.s3.amazonaws.com/0/5774/14a86993-cac8-301d-0da0-a5861fb1e267.png)


## おわりに

Nuxt.jsを使えばMessaging API・LIFF（LINE PAY）等、LINEボットの開発を簡単にはじめられるので、とってもオススメです。