対話型LINE BOTの作り方002 (猫の写真を送る)

#概要

[前回の投稿](https://qiita.com/mahiroaug/items/b0e23e6ca19c33add1a9)において対話型LINE BOTの基本機能を実装できたので、応用編としてリプライ機能の向上を図っていく。今回作成するのはテキストメッセージの代わりに画像メッセージをユーザへリプライする機能である。一例として監視カメラの画像をLINEを通じてオンデマンドに表示させるソリューションを実装していきたい。

##やりたいこと

自宅の猫の様子を監視カメラで撮影し、LINEアプリを通じてウォッチしたい。

##メッセージ仕様

[LINE Messaging APIリファレンス - 画像メッセージ](https://developers.line.biz/ja/reference/messaging-api/#image-message)
によるとBOTはLINEメッセージ内で画像ファイルを直接やりとりするのではなく、インターネットのリンク情報を通知するものらしい。
つまりメッセージ本文中には画像のバイナリデータは一切含まれず、LINEアプリが改めてURL先からデータを直接取得してくるという仕組みのようだ。こうすることによってLINEとしてはデータ中継の手間がなくなりトラフィック削減に寄与できるとともに、ユーザにとっては遅延が少なくなるためユーザ満足度も向上するという双方のメリットを考慮しているものと想定される。よって、画像ファイルはインターネットに対してReachableな場所に配置する必要があることがわかる。


##システム構成

自宅に設置した自作の監視カメラで監視対象物（猫）を動体検知により撮影し、WEBサーバにアップロードする。これはBOTのプログラムとは関係なくカメラ側のシステム起因で定期的に行うこととする。
一方、BOTはLINEアプリからの要求(①)に応じてユーザへ画像ファイルのURLを通知(②)し、LINEアプリが画像をダウンロード(③④)する。
自宅内のネットワークとLINE BOTを直接連動させないのはセキュリティの観点からの配慮である。そのため厳密にはリアルタイムの監視ではなく撮影後しばらく経過した画像が表示される仕様となる。

![システム構成002c.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/55f98482-f17c-fd41-00b5-547b69cb76c0.png)


#1.カメラユーザ作成

```none:カメラユーザ作成
# useradd camera
# passwd camera
# mkdir camera/upload
# chown camera:camera camera/upload
# chmod 700 camera/upload
```

#2.FTP設定

```none:ftpサーバインストール
$ sudo -s
# yum -y install vsftpd
```

```bash:/etc/vsftpd/vsftpd.conf

# 12行目：匿名ログイン禁止
anonymous_enable=NO

# 82,83行目：コメント解除 (アスキーモードでの転送を許可)
ascii_upload_enable=YES
ascii_download_enable=YES

# 100,101行目：コメント解除 (chroot有効)
chroot_local_user=YES
chroot_list_enable=YES

# 103行目：コメント解除 (chroot リストファイル指定)
chroot_list_file=/etc/vsftpd/chroot_list

# 109行目：コメント解除 (ディレクトリごと一括での転送有効)
ls_recurse_enable=YES

# 114行目：変更 (IPv4をリスンする)
listen=YES

# 123行目：変更 (もし不要なら IPv6 はリスンしない)
listen_ipv6=NO

# ルートディレクトリ指定 (指定しない場合はホームディレクトリがルートディレクトリとなる)
local_root=public_html

# ローカルタイムを使う
use_localtime=YES

# seccomp filter をオフにする (ログインに失敗する場合はオフにする)
seccomp_sandbox=NO

#パッシブモードの設定
pasv_enable=YES
pasv_min_port=0
pasv_max_port=0
```

chrootしない（ローカルディレクトリより上層へのcdを許可するユーザ）リストを作成する

```bash:/etc/vsftpd/chroot_list
  #空のファイルでもいいので存在している必要がある
```

ftpログインを禁止するユーザを指定

```bash:/etc/vsftpd/ftpusers
  #必要に応じて追記
```

```none:起動
# service vsftpd start
vsftpd 用の vsftpd を起動中:  [  OK  ]
```

```none:自動起動設定
# sudo chkconfig vsftpd on
# chkconfig --list | grep ftp
vsftpd 0:off 1:off 2:on 3:on 4:on 5:on 6:off
```


###iptablesの設定
アクティブモードだとクライアント側のセキュリティソフト等で弾かれるためパッシブモードの設定を行う必要がある

>[iptablesでFTP(パッシブモード)を許可する](http://cos.linux-dvr.biz/archives/155)

```none:21番ポート開放
# iptables -A SERVICES -p tcp --dport 21 -j ACCEPT
```

```bash:/etc/sysconfig/iptables-config
IPTABLES_MODULES="ip_conntrack_ftp ip_nat_ftp"
```

>引用：「ip_conntrack_ftp」モジュールはFTPプロトコルを識別し、使うポートを追跡して適宜開けてくれます。(つまり、ポートレベルでフィルタリングするのではなく、Windowsファイアウォールなどと同じようにアプリケーションレベルでフィルタリングしてくれます。)
また、IPマスカレードを使用している場合は、「ip_nat_ftp」モジュールもロードします。

便利なモジュールがあるんですね。

```none:設定反映
# service vsftpd restart
iptables: チェインをポリシー ACCEPT へ設定中filter         [  OK  ]
iptables: ファイアウォールルールを消去中:                  [  OK  ]
iptables: モジュールを取り外し中:                          [  OK  ]
iptables: ファイアウォールルールを適用中:                  [  OK  ]
iptables: 追加のモジュールを読み込み中:ip_conntrack_ftp ip_[  OK  ]
```

#3.監視カメラ作成

試験的に簡易な方法で監視カメラを作成する。
用いるもの

 - PC( `windows` )
 - USBカメラ
 - キャプチャソフト( `LiveCapture3`)

##キャプチャソフトのFTP設定

今回採用した[LiveCapture3](https://lc3.daddysoffice.com/livecapture3/)は、USBカメラなど様々なデバイスに対応しており、静止画や動画をキャプチャしてメール送信、FTP送信できるほか、RTP配信にも対応している高機能フリーウェアである。
指定したタイミング(周期)での定期撮影もできるほか、動体検知により指定したFTPサーバへ画像をアップロードする機能まで、フリーウェアでありながらこれらの機能を全て単体で有しており、今回のニーズに完璧に合致する神ツールである。感謝。

- 動作モード: 動体検知
- アクション: 静止画をFTPにアップロード
- 転送モード: PASV

上記設定でFTPサーバに画像が送信されることを確認。

#4.画像ファイルのリサイズ

[LINE Messaging APIリファレンス - 画像メッセージ](https://developers.line.biz/ja/reference/messaging-api/#image-message)
によると画像ファイルの要件は下記の通り。

- originalContentUrl
    - JPEG
    - 最大画像サイズ：4096×4096
    - 最大ファイルサイズ：1MB
- previewImageUrl
    - JPEG
    - 最大画像サイズ：240×240
    - 最大ファイルサイズ：1MB

これらに適合させるべくサーバ側で画像ファイルのリサイズ処理を行う。
ImageMagickというツールが便利な模様。

>(参考) [CentOS7にImageMagickをインストールしてconvertコマンドを利用してみた](https://memento-mori.jp/blog/server/imagemagick-convert)

```none:ImageMagickインストール
# yum -y install ImageMagick ImageMagick-devel
```

```none:画像情報を取得する
# identify -verbose a.jpg
Image: a.jpg
  Format: JPEG (Joint Photographic Experts Group JFIF format)
  Class: DirectClass
  Geometry: 1280x720+0+0
  Resolution: 96x96
  Print size: 13.3333x7.5
```

>convertコマンドの主なオプション
`-resize`
　「横×縦」の形式で指定すれば、その枠に収まるようにアスペクト比固定でリサイズ
`-auto-orient`
　画像のexif情報を元に自動回転
`-strip`
　画像のexif情報を除去

下記のリサイズ案を採用する。

```none:画像リサイズ
# convert -strip -resize 896x504 source_file 閲覧用
# convert -strip -resize 179x100 source_file プレビュー用
```

また公開ファイルへの第三者による無作為アクセス対策としてハッシュ値を算出しファイル名に付加しておく。こうすることによってURLの類推を防止できる。

```none:ハッシュ値計算
# md5sum source_file
```

以上を踏まえ、公開用ディレクトリに画像を保存(オーバーライド)するスクリプトを記述する。

```bash:camera.sh
#/!bin/bash

CP='/bin/cp'
RM='/bin/rm'
CONV='/usr/bin/convert'

date=`date '+%Y%m%d%H%M%S'`
SRC_DIR='/home/home/upload' #カメラ画像保存ディレクトリ
DST_DIR='/usr/share/nginx/html/media' #公開ディレクトリ

LATEST_DIR=`ls -rt ${SRC_DIR} | tail -n 1`
LATEST_FILE=`ls -rt ${SRC_DIR}/${LATEST_DIR} | tail -n 1`
LATEST_FILE_PATH=${SRC_DIR}/${LATEST_DIR}/${LATEST_FILE}

MD5=($(md5sum ${LATEST_FILE_PATH}))
DST_FILE_PATH=${DST_DIR}/${LATEST_FILE%.*}_org_${MD5}.${LATEST_FILE##*.}
DST_FILE_PREV=${DST_DIR}/${LATEST_FILE%.*}_prev_${MD5}.${LATEST_FILE##*.}

${RM} -r ${DST_DIR}/*
${CP} ${LATEST_FILE_PATH} ${DST_FILE_PATH}
${CONV} -strip -resize 896x504  ${DST_FILE_PATH} ${DST_FILE_PATH}
${CONV} -strip -resize 179x100  ${DST_FILE_PATH} ${DST_FILE_PREV}
```

このシェルをcronに登録して繰り返し周期的に実行し続ける。するとSRC_DIRに格納されている画像ファイルの中から常に最新のファイルが選択されて、DST_DIRの公開用ファイルを上書きし続ける。つまり最後に動体検知した瞬間の静止画（＝最新の猫の状態）がLINEに通知される仕組みが出来上がる。

```none:cron設定
# crontab -e
* * * * * /home/camera/script/camera.sh 1> /dev/null
```


#5.LINE BOTに画像メッセージリプライ機能を追加

前回作成したBOTに画像メッセージ機能をアドオンする。
ユーザから送信するメッセージの中に特定のワード（起動コード）が含まれていた場合に条件分岐し、対話APIに投げるのではなく画像メッセージをリプライするように仕向ける。
今回設定した起動コードは以下の４パターンである。いずれかがユーザのメッセージ文中に含まれていた場合、（多少強引であるが）会話の文脈に関係なく処理を分岐させるようにする。

```python:起動コード
CATVIEW_KEY = ['カメラ','室内','部屋','猫']
```

またレスポンスにも、画像メッセージと併せてランダムなコメントを挿入させてみる。例えば以下のようなものである。

```python:レスポンス
CATVIEW_RES = ['カメラの画像を送るよ',
               '猫ちゃんの様子です',
               '自宅警備中です',
               '写真で確認よろしく']
```

以下に出来上がったコード全体を示す。
def action_res
def response_image
の関数を新規作成し、line_handlerの#action部で起動コードの判定を行っている。

```python:bot/chatbot.py
# -*- Coding: utf-8 -*-

from django.views.decorators.csrf import csrf_exempt
from django.http import HttpResponse
from django.shortcuts import render
from datetime import datetime
from time import sleep
import requests
import json
import base64
import logging
import os
import random
import log.logconfig
from utils import tools

logger = logging.getLogger('commonLogging')

LINE_ENDPOINT = 'https://api.line.me/v2/bot/message/reply'
LINE_ACCESS_TOKEN = '' #Line developer管理画面で確認
LINE_ALLOW_USER='' #Line developer管理画面で確認

ULOCAL_ENDPOINT = 'https://chatbot-api.userlocal.jp/api/chat'
ULOCAL_API_KEY = '' #Userlocal登録時に確認

IMAGE_DIR = '/usr/share/nginx/html/media'
IMAGE_URL = 'https://mydomain.com/media'

CATVIEW_KEY = ['カメラ','室内','部屋','猫']
CATVIEW_RES = ['カメラの画像を送るよ',
               '猫ちゃんの様子です',
               '自宅警備中です',
               '写真で確認よろしく']


@csrf_exempt
def line_handler(request):

    #exception
    if not request.method == 'POST':
      return HttpResponse(status=200)

    logger.debug('line_handler message incoming') #logging
    out_log = tools.outputLog_line_request(request) #logging
    request_json = json.loads(request.body.decode('utf-8'))

    for event in request_json['events']:
      reply_token = event['replyToken']
      message_type = event['message']['type']
      user_id = event['source']['userId']

      #whitelist
      if not user_id == LINE_ALLOW_USER:
        logger.warning('invalid userID:' + user_id) #logging
        return HttpResponse(status=200)

      #action
      if message_type == 'text':
        if any(s in event['message']['text'] for s in CATVIEW_KEY):
          action_res(reply_token,'catview')

        else:
          #ulocal chat
          response_text(reply_token,ulocal_chatting(event))

    return HttpResponse(status=200)

def action_res(reply_token,command):
    if command == 'catview':
    #監視カメラ画像
      files = os.listdir(IMAGE_DIR)
      #logger.info(files) #logging
      orgFiles = [ s for s in files if '_view' in s ]
      orgPATH = IMAGE_DIR + '/' + orgFiles[0]
      orgUrl = IMAGE_URL + '/' + orgFiles[0]
      subtext = random.choice(CATVIEW_RES)

      sleep(1)
      response_image(reply_token,orgUrl,orgUrl,subtext)


def response_image(reply_token,orgUrl,preUrl,text):
    payload = {
      "replyToken": reply_token,
      "messages":[
        {
          "type": 'text',
          "text": text
        },
        {
          "type": 'image',
          "originalContentUrl": orgUrl,
          "previewImageUrl": preUrl
        }
      ]
    }
    line_post(payload)


def response_text(reply_token,text):
    payload = {
      "replyToken": reply_token,
      "messages":[
        {
          "type": 'text',
          "text": text
        }
      ]
    }
    line_post(payload)


def line_post(payload):
    url = LINE_ENDPOINT
    header = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + LINE_ACCESS_TOKEN
    }
    requests.post(url, headers=header, data=json.dumps(payload))
    out_log = tools.outputLog_line_response(payload) #logging
    logger.debug('line_handler message -->reply') #logging


def ulocal_chatting(event):
    url = ULOCAL_ENDPOINT   
    payload={
      'key'    : ULOCAL_API_KEY,
      'message': event['message']['text']
    }  

    out_log = tools.outputLog_ulocal_request(payload) #logging
    logger.debug('ulocal_chatting send request') #logging
    ulocal_res = requests.get(url,payload)
    logger.debug('ulocal_chatting -->recv response') #logging
    out_log = tools.outputLog_ulocal_response(ulocal_res) #logging

    data = ulocal_res.json()
    response = data['result']
    return response
```

```bash:line_botを起動
(botenv) [line_bot]$ gunicorn --bind 127.0.0.1:8000 line_bot.wsgi:application
```

LINEアプリから起動コードを含むメッセージを投下してみると応答が得られることがわかる。

![Screenshot120b.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/484220/971b6fda-75fe-9780-b328-2520c1420bd3.png)

リプライコメントはランダムに選択されるため会話が微妙に噛み合わない感じになるが、この馬鹿っぽさがなんとも憎めない。
今回はここまで。