Docker + Node.jsでLINE BOTを書いてみる

いまさらながら、DockerとNode.jsを使ってサンプルのLINE BOTを書いてみた。ソースは[Github](https://github.com/nosu/lottery-bot)にあります。

送った人名リストから、ランダムで指定の人数の当たりを選んでくれるBOTです。こんな感じで使います。

<img width="400px" alt="Screenshot_20160620-001906.png" src="https://qiita-image-store.s3.amazonaws.com/0/8548/25160b7e-0edf-a68f-318e-0c01693fe2df.png">

## ポイント
- BOT側のcallbackのエンドポイントはHTTPS化が必須。ありがとう[Let's Encrypt](https://letsencrypt.org/)！（以前Let's EncryptではBOT APIが利用できない問題があったようですが、今は解消しています）
- メッセージを送る際、`body.to`には宛先のIDを**配列で**渡す必要がある。間違えるとステータスコード`400`で返ってくる（はまった）
- DockerでNodeを使う際の`node_modules`の扱いについては、[POSTDの記事](http://postd.cc/lessons-building-node-app-docker/)が参考になる
- 残念ながら、現状のLINE BOT APIは、グループチャットをサポートしていないため、グループ参加者から自動で選んでくれるようなことはできません。本当はそれがやりたかった

## 使用方法
1. https://developers.line.me からなんやかんやしてアカウント作成
2. BOT用のドメインと、SSL証明書を用意。証明書持ってない方は、[Let's Encrypt](https://letsencrypt.org/)などを使うと良いと思います。
3. `$ git clone https://github.com/nosu/lottery-bot.git`
4. サンプルファイルを参考に、1.で作成したアカウントのトークン系を環境変数ファイルに記載（`$ cp .env.sample .env && vi .env`）
5. `docker-compose.yml`を開き、ホスト側ポート番号を適当に変更
6. `$ docker-compose build`
7. `$ docker-compose up`

## ソース

```js:app.js
'use strict';
const app        = require('express')();
const bodyParser = require('body-parser');
const request    = require('request');

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

const BASE_URI = "https://trialbot-api.line.me";
const SEND_URI = BASE_URI + '/v1/events';

/**
 * The sessions obj stores the id who sent the message and the list of the person names.
 * Each session will be deleted automatically SESSION_EXPIRE(ms) later.
 * Example: { ubcf6832fae5a186d9b8ac7261e3ff000: ['John', 'Doe'] }
 */
const SESSION_EXPIRE = 20000; // 20 seconds
let sessions = {};

app.post('/callback', (req, res) => {
  const body = req.body;
  body.result.forEach((msg) => {
    if(!sessions[msg.content.from]) {
      // if the session doesn't exist
      console.log('content: ', msg.content);
      const persons = msg.content.text.split('\n').map(p => p.trim());
      if(persons.length >= 2) {
        addSessionWithTimer(msg.content.from, persons, SESSION_EXPIRE);
        const newMsg = `${persons.join("さん, ")}さんから何人選ぶ？数字で教えてー`;
        console.log('sendMessage');
        sendMessage(newMsg, [msg.content.from]);
      } else {
        const newMsg = "1人だけじゃ抽選できないよ！";
        sendMessage(newMsg, [msg.content.from]);
      }
    } else {
      // if the session exists
      console.log('sessions: ', sessions);
      const matchResult = msg.content.text.match(/([0-9]+)/);
      const persons = sessions[msg.content.from];
      if(matchResult) {
        const numberOfWinners = parseInt(matchResult[0]);
        const winners = chooseRandomItems(persons, numberOfWinners);
        const newMsg = `${winners.join("さん, ")}さんが当たりだよ！`;
        sendMessage(newMsg, [msg.content.from]);
      } else {
        const winners = chooseRandomItems(persons, 1);
        const newMsg = `よくわからないから1人だけ選んだ結果…\n${winners[0]}さんが当たりだよ！`;
        sendMessage(newMsg, [msg.content.from]);
      }
      deleteSession(msg.content.from);
    }
  });
  res.send('OK');
});

const addSessionWithTimer = (id, persons, expire) => {
  sessions[id] = persons;
  setTimeout(deleteSession.bind(null, id), expire);
};

const deleteSession = (id) => {
  console.log(`The session <${id}> is deleted.`);
  delete sessions[id];
};

const chooseRandomItems = (array, number) => {
  let a = array.concat();
  let r = [];
  let l = array.length;
  let n = Math.min(number, array.length);
  while(n-- > 0) {
    let i = Math.floor(Math.random() * l--);
    r.push(a[i]);
    a.splice(i, 1);
  }
  return r;
};

const sendMessage = (text, toId) => {
  return new Promise((resolve, reject) => {
    const options = {
      uri: SEND_URI,
      method: "POST",
      json: true,
      headers: {
        "Content-Type": "application/json; charset=UTF-8",
        "X-Line-ChannelID": process.env.LINE_CHANNEL_ID,
        "X-Line-ChannelSecret": process.env.LINE_CHANNEL_SECRET,
        "X-Line-Trusted-User-With-ACL": process.env.LINE_CHANNEL_MID,
      },
      body: {
        to: toId,
        toChannel: 1383378250, // Fixed value
        eventType: "138311608800106203", // Fixed value
        content: {
          contentType: 1,
          toType: 1,
          text: text,
        }
      }
    };
    console.log('options: ', options);
    request(options, (err, res, body) => {
      if(err) {
        reject(new Error(err));
      }
      console.log('request complete without error\nbody: ', body);
      resolve(body);
    });
  });
};

app.listen(3000, () => {
  console.log('Listening on port 3000');
});
```

```Dockerfile:Dockerfile
FROM node:6.2.0

RUN useradd --user-group --create-home --shell /bin/false app &&\
  npm install --global npm@3.9.6

ENV HOME=/home/app

COPY package.json $HOME/bot/
RUN chown -R app:app $HOME/*

USER app
WORKDIR $HOME/bot
RUN npm install

USER root
COPY . $HOME/bot
RUN chown -R app:app $HOME/*
USER app

CMD ["node", "app.js"]
```

```yaml:docker-compose.yml
version: '2'
services:
  bot:
    build:
      context: .
      dockerfile: Dockerfile
    command: node_modules/.bin/nodemon app.js
    environment:
      NODE_ENV: development
    env_file: .env
    ports:
      - '30002:3000'
    volumes:
      - .:/home/app/bot
      - /home/app/bot/node_modules
```