line bot を作ってみる

プロトタイピングスクールProto out studioの第1回目の宿題として、
line botを作成したので、制作時のあれこれ投稿します。
作成にあたって以下の条件がありました。

- 一つ以上のAPIを使用すること
- 言語はjs

## 何作る？

最初に考えてたのは、普段の生活のこまごましたところを世話してくれるようなボット<br>

- その日の天気を教えてくれる（出勤前に傘持って行った方がいいよとか）<br>[すでにやってる方がいました](http://takagusu.hateblo.jp/entry/2017/01/24/200453)
- 終電近くなったら教えてくれる
- 電車遅延あったら教えてくれる
- 英語翻訳してくれる
- 予定とかリマインダー

オールインワン的なのが欲しくて考えてたのですが、、
終電時間とか遅延取れるAPI見つけられず、、今回は見送りました。
ということで、まずは第一歩、上記の**「英語翻訳してくれる」**を実装した英語学習支援ボット作ってみました。

## モチベーション
英語力欲しい、、もうちょっと楽に英語の記事読めるようになりたい。。
英語の記事に出会うたびに、辞書で調べてその時だけの理解で終わっちゃう。<br>何回も同じ単語調べてる。。<br>
今回作ったのは、そんな自分を戒めるための<br>辞書兼単語帳兼調べた単語のリマインダー的なボット。

## 仕様

- 英語を送るといくつか翻訳を返す
- 送ったことがある単語は「教えたよ、思い出して」で返して自力で思い出すよう促す（単語のテストする）
- テストモードで今まで翻訳してもらった単語のテストをする

## 作ってみる

### 開発環境

- node.js　v11.10.0
- mac os 10.13.6

#### 使ったnode.jsのモジュール

- express 4.17.1
- @line/bot-sdk　6.7.1
- firebase-admin　8.1.0
- request 2.88.0

### 準備

[@n0bisukeさんの記事](https://qiita.com/n0bisuke/items/ceaa09ef8898bee8369d)の手順でボットアカウントとexpressのベースを作成しておきます。

### 使ったAPI

- [Translator Text APIの辞書検索](https://docs.microsoft.com/ja-jp/azure/cognitive-services/Translator/reference/v3-0-dictionary-lookup?tabs=curl)
  - 翻訳の候補を複数返してくれる
  - 逆翻訳した類語も返してくれる（逆翻訳というのをちゃんと理解していないので、類語という認識てあっているのかあんま自信ない、、）
  - [「辞書の例」](https://docs.microsoft.com/ja-jp/azure/cognitive-services/Translator/reference/v3-0-dictionary-examples?tabs=curl)を合わせて使用すると、例文もとれる
  - 最大10個、最大文字数スペースも含めて 100 文字

あとは、単語の記録用にfirebaseのCloud FireStore使いました。<br>
Cloud FireStoreはないけどfire base触ったことがあったからという理由だけで特に理由はありません。

### こんな感じになりました

**[![IMAGE ALT TEXT HERE](http://img.youtube.com/vi/qvdxAsUKptU/0.jpg)](http://www.youtube.com/watch?v=qvdxAsUKptU)**


#### 辞書モード
1. 単語送る
2. 翻訳返す

#### リマインドモード
1. 翻訳済みの単語送る
2. 「この前教えたーよ　思い出してみ」でテスト開始
3. 翻訳を送る
4. **【正解時】**「正解！他にもこんな感じで翻訳できたり」で送ったその他の翻訳候補を返す<br>
**【不正解時】**「違うなー」でテスト継続
**【ごめん、教えてください】**正解を教えてくれる

※翻訳候補のいづれかに一致した場合に正解としています。
※完全一致で見ているので、漢字で翻訳が登録されているものは、ひらがなで入れても不正解となります。（改善したい）

#### テストモード
1. 「マスターしたかも」でテスト開始
2. リマインドモードと同じ流れで、一単語づつテストしていく

### 実装
以下2ファイルに分けました。
- メインのサーバー用(server.js)
- APIへリクエスト＆翻訳返す(translate.js)

#### server.js

メインの返信処理は**handleEvent**内に記述
それ以外は、
Cloud FireStoreとline sdkの初期化、
expressとメイン処理で使っている関数の記述になります。
初Cloud FireStoreだったので、そこでちょっと時間かかっちゃいました。。
単語登録をupdateでやったりsetに{merge: true}つけずにやって、上書きされて登録されないってなってました。

```js
'use strict';

const express = require('express')
const line = require('@line/bot-sdk')
const PORT = process.env.PORT || 3000;
const JSONParseError = require('@line/bot-sdk').JSONParseError
const SignatureValidationFailed = require('@line/bot-sdk').SignatureValidationFailed
const serviceAccount = require('{Fire base秘密鍵のJSONへのパス}')
const admin = require("firebase-admin")
const translate = require('./translate')

//fire base認証初期化
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: 'dbのURL'
})

//dbの参照取得
const db = admin.firestore()
const collection = db.collection('users')

//line　初期化用アクセストークン
const config = {
  channelSecret: '{チャンネルシークレット}',
  channelAccessToken: '{チャンネルアクセストークン}'
}

//expressルーティング指定(/webhookで受ける)
const app = express()

app.post('/webhook', line.middleware(config), async (req, res) => {
    Promise
      .all(req.body.events.map(handleEvent))
      .then(result => res.json(result))
})

//line sdk初期化(clientを通してメッセージを送る)
const client = new line.Client(config);

//メインの処理
async function handleEvent(event) {

  //テキスト以外は処理しない
  if (event.type !== 'message' || event.message.type !== 'text') {
    return Promise.resolve(null);
  }

  const message = event.message.text
  const replyToken = event.replyToken

  const userId = event.source.userId
  const doc = collection.doc(userId)
  const snapShot = await getSnapShot(doc)

  //「マスターしたかも」でテストモード開始
  if (snapShot && snapShot.word && message === 'マスターしたかも') {
    const current = await startExsam(doc, snapShot)
    return pushMsg(userId, 'おっじゃテストしますか').then(async () => {
      const answer = parseAnswer(current.answer, 'array')
      await startTest(doc, answer)
      return reply(replyToken, current.word)
    })   
  }

  //テストモード
  if (snapShot.test && snapShot.test.start) {
    const isCorrect = snapShot.test.answer.includes(message)
    const isGiveUp = message === 'ごめん、教えてください'
    const isExsamGiveUp = message === 'ごめん、やっぱテスト無理'
    const shuldUpdateExsam = snapShot.exsam && snapShot.exsam.start && isCorrect || isGiveUp
    let answer;
    let nextAnswer;
    let current;

    if (isExsamGiveUp) {
      finishExsam(doc)
      return reply(replyToken, 'おつ！')
    }

    if (shuldUpdateExsam) {
      current = await updateExsam(doc, snapShot)
      nextAnswer = current ? parseAnswer(current.answer, 'array') : current
    }

    if (isCorrect || isGiveUp) {
      answer = parseAnswer(snapShot.test.answer)
      const replyMsg = isGiveUp ? `しゃーないなー\nほれ！\n\n${answer}` : `正解！\n他にもこんな感じで翻訳できたり\n\n${answer}`
      updateTest(doc, nextAnswer)
      return pushMsg(userId, replyMsg).then(() => {
        return afterReplyAnswer(shuldUpdateExsam, nextAnswer, replyToken, current ? current.word : null)
      })
    } else {
      return pushMsg(userId, '違うなー')
    }
  }

  //テストモード以外のときの日本語への返し
  if (!isOnlyAlphabet(message)) {
    return reply(replyToken, '日本語では？')
  }

  const alreadyRegistered = await hasWord(snapShot, message)

  //一度登録した単語の時返し
  //テストモード開始
  if (alreadyRegistered) {
    const answer = snapShot.word[message].split('\n').map(item => item.replace(/^・/, ''))
    await startTest(doc, answer)
    return reply(replyToken, 'この前教えたーよ\n思い出してみ')
  }

  //以降通常の翻訳の返し

  pushMsg(userId, 'ちょい待ち！')

  let translated = await translate(message)
  const word = {text: message, translated}

  await registerWord(doc, word)
  await pushMsg(userId, translated)
  return reply(replyToken, 'だよー')
}

async function startExsam(doc, snapShot) {
  const list = Object.keys(snapShot.word)
  const current = {word: list[0], answer: snapShot.word[list[0]]}
  await doc.set({
    exsam: {
      start: true,
      current: {word: list[0], answer: snapShot.word[list[0]]},
      list
    }
  }, {merge: true})
  return current
}

async function updateExsam(doc, snapShot) {
  const _list = [...snapShot.exsam.list]
  _list.shift()
  const start = _list.length > 0
  const exsam = {
    start,
    current: start ? {word: _list[0], answer: snapShot.word[_list[0]]} : null,
    list: start ? _list : null
  }
  await doc.set({exsam}, {merge: true})
  return exsam.current
}

function finishExsam(doc) {
  doc.set({
    exsam: {
      start: false,
      current: null,
      list: null
    },
    test: {
      start: false
    }
  }, {merge: true})
}

async function startTest(doc, answer) {
  await doc.set({
    test: {start: true, answer},
  }, {merge: true})
}

function updateTest(doc, nextAnswer) {
  const shouldContinue = nextAnswer !== undefined && nextAnswer !== null
  doc.set({
    test: {
      start: shouldContinue ? true : false,
      answer: shouldContinue ? nextAnswer : []
    },
  }, {merge: true})
}

async function registerWord(doc, word) {
  await doc.set({
    word: { [word.text]: word.translated}
  }, {merge: true})
}

function isOnlyAlphabet(word) {
  return /^[a-zA-z\s]+$/.test(word)
}

function getSnapShot(doc) {
  return doc.get().then(doc => {
    return doc.data()
  })
}

function hasWord(snapShot, text) {
  if (!snapShot) return false
  return snapShot.word && snapShot.word[text] ? true : false
}

function parseAnswer(answer, to) {
  if (to === 'array') {
    return answer.split('\n').map(item => item.replace(/^・/, ''))
  } else {
    return answer.map(item => `・${item}`).join('\n')
  }
}

function afterReplyAnswer(shuldUpdateExsam, nextAnswer, replyToken, nextQuestion) {
  return nextAnswer ? 
    reply(replyToken, nextQuestion) : 
    shuldUpdateExsam ? reply(replyToken, 'お疲れーこれで全部だよー') : Promise.resolve()
}

function reply(replyToken, message) {
  return client.replyMessage(replyToken, {
    type: 'text',
    text: message
  })
}

function pushMsg(userId, message) {
  return client.pushMessage(userId, {
    type: 'text',
    text: message,
  })
}

app.use((err, req, res, next) => {
  if (err instanceof SignatureValidationFailed) {
    res.status(401).send(err.signature)
    return
  } else if (err instanceof JSONParseError) {
    res.status(400).send(err.raw)
    return
  }
  next(err) // will throw default 500
})

app.listen(PORT)

console.log(`Server running at ${PORT}`);
```

#### translate.js

```js
const request = require('request')
const uuidv4 = require('uuid/v4')
const subscriptionKey = '{サブスクリプションキー}'

function translateText(text){
  let options = {
    method: 'POST',
    baseUrl: 'https://api.cognitive.microsofttranslator.com/',
    url: '/dictionary/lookup',
    qs: {
      'api-version': '3.0',
      'from': 'en',
      'to': 'ja'
    },
    headers: {
      'Ocp-Apim-Subscription-Key': subscriptionKey,
      'Content-type': 'application/json',
      'X-ClientTraceId': uuidv4().toString()
    },
    body: [{text}],
    json: true,
  }
  return req(options)
}

function req(options) {
  return new Promise(resolve => {
    request(options, function(err, res, body){
      const translatedItems = body[0].translations.map(item => {
        return `・${item.displayTarget}`
      })
      resolve(translatedItems.join('\n'))
    })
  })
}

module.exports = translateText
```


## 所感
最初は、すでにあるんだろうなーとか思ってあまりモチベーションが上がらなかったんですが、
この時はこの言葉で返そうみたいな返信の言葉考えるのとか結構楽しくて、
最終的には愛着みたいなのも湧いてきたので、もっとブラッシュアップして自分で使えるものにしたいなと思いました。

自分が作ったものに対して、こういう愛着みたいな感情は、今ままで経験したことないかも。
ちょっとした人格をもつbot特有なのかもしれません。

あと、対話式って一人黙々勉強するよりもモチベーションが維持できる気がしました。
今回は単語帳の機能を対話式にしただけだけど、
他のものでも、一人でできるようなことを対話式で進められるようにするだけで
だいぶ使ってるときの感覚が違ってくるんだろうなーという気づきがありました。

## ブラッシュアップしたいとこ
- テストのときに、完全一致じゃないと正解にならないので、ひらがなで入れても通るようにしたい
- 翻訳した時に、類似する英語も表示したい
- 例文も表示したい（毎回翻訳と一緒に出すとごちゃつくので、「例文ちょうだい」みたいなコマンドで表示？）
- テストのスコア機能つけたい（何問正解だよーおめでとう！みたいな）
- 文章翻訳も入れる？
- webにページ用意してそこからも登録した単語を閲覧編集できるようにしとく
- 全然触らなくなると、「最近サボってない？」的なメッセージ届ける
- 発話機能

## おまけ
[こちら](https://qiita.com/youwht/items/f21325ff62603e8664e6)の記事にある機械学習で対義語生成に感動したので、
記事のコードを拝借して対義語ボット作ってみました。
仕事に疲れた帰りの電車でユーモアいただいています。
人知を超えた言葉を返してくれます。
現代詩チックな想像の斜め上をいく言葉を発するので、
アイディアの生成に役立ちそうな、どうやったらいいのかはおいおい。