【Rails】LINE Messaging APIでデータを保存してecho bot作る

2016年10月2日現在のLINE Messaging APIを送信、受信のAPIを大体試します。
Echo BotをRuby, Railsで実装し、全てのメッセージタイプの保存してエコーする実装を、テストまで含め紹介します。
** スキーマなどの詳細は追って追記します
** Template Messageも追って追記します

#前提
バリデーションやエラーハンドリングなど例外的な処理は書きません。
動画はmp4、音声はm4a、画像はjpgの前提とする。
 
#使ったライブラリ
```ruby:Gemfile
gem 'carrierwave' # 画像、動画、音声の保存
gem 'streamio-ffmpeg' # ffmpegのラッパー。動画のサムネを抽出
gem 'ruby-audioinfo' # 音声の時間の抜き取る
gem 'rmagick' # 画像加工
gem 'fog' # awsに画像、動画、音声を保存する
gem 'line-bot-api' # ruby版Lineの公式sdk

group :test do
  gem "rspec-rails", '= 3.5.0'
  gem 'factory_girl_rails'
  gem 'rspec', '= 3.5.0'
  gem 'rspec-core', '= 3.5.0'
  gem 'rspec-expectations', '= 3.5.0'
  gem 'rspec-mocks', '= 3.5.0'
  gem 'rspec-support', '= 3.5.0'

  gem 'database_cleaner'
  gem 'webmock'
  gem 'fakeweb'
  gem 'addressable'
end
```

# 環境にインストールしないといけない
ffmpeg
rmagic

#実装

## controller
```ruby:app/controllers/line_controller.rb
class LineController < ApplicationController
  MESSAGE_TYPE_TO_METHOD_MAP = {
    "text" => :echo_text,
    "image" => :echo_image,
    "video" => :echo_video,
    "audio" => :echo_audio,
    "location" => :echo_location,
    "sticker" => :echo_sticker,
  }.freeze
  protect_from_forgery with: :null_session

  def echo
    signature = request.env["HTTP_X_LINE_SIGNATURE"]
    body = request.body.read
    unless client.validate_signature(body, signature)
      head :bad_request
      return
    end

    events = client.parse_events_from(body)
    events.each do |event|
      message_target = find_or_create_message_target(event) ## message_group or message_user or message_roomが入る。透過的に扱えるように設計しておく。

      case event
      when Line::Bot::Event::Message
        message = Message.new(message_target_id: message_target.id, message_target_type: event["source"]["type"], message_type: event.type.to_sym, chat_id: message_target.chat_id)
        send(MESSAGE_TYPE_TO_METHOD_MAP[event.type.to_s], message, event)
      when Line::Bot::Event::Follow
        receive_follow(message_target)
      when Line::Bot::Event::Unfollow
        receive_unfollow(message_target)
      when Line::Bot::Event::Join
        receive_join(message_target)
      when Line::Bot::Event::Leave
        receive_leave(message_target)
      end
    end
    head :ok
  end

  def echo_text(message, event)
    MessageText.create!(message: message, value: event.message["text"])
    client.reply_message(event['replyToken'], {
      type: "text",
      text: message.message_text_value
    })
  end

  def echo_image(message, event)
    image_response = client.get_message_content(event.message['id'])
    file = File.open("/tmp/#{SecureRandom.uuid}.jpg", "w+b")
    file.write(image_response.body)
    MessageImage.create!(message: message, value: file)
    File.unlink(file)
    client.reply_message(event['replyToken'], {
      type: "image",
      originalContentUrl: message.message_image_url,
      previewImageUrl: message.message_image_thumbnail_url
    })
  end

  def echo_video(message, event)
    video_response = client.get_message_content(event.message['id'])
    file = File.open("/tmp/#{SecureRandom.uuid}.mp4", "w+b")
    file.write(video_response.body)
    MessageVideo.create!(message: message, value: file)
    File.unlink(file)
    client.reply_message(event['replyToken'], {
       type: "video",
       originalContentUrl: message.message_video_url,
       previewImageUrl: message.message_video_thumbnail_url
    })
  end

  def echo_audio(message, event)
    image_response = client.get_message_content(event.message['id'])
    file = File.open("/tmp/#{SecureRandom.uuid}.m4a", "w+b")
    file.write(image_response.body)
    MessageAudio.create!(message: message, value: file)
    File.unlink(file)
    client.reply_message(event['replyToken'], {
      type: "audio",
      originalContentUrl: message.message_audio_url,
      duration: message.message_audio_duration * 1000
    })
  end

  def echo_location(message, event)
    message_param = event.message
    title = message_param["title"]
    address = message_param["address"]
    latitude = message_param["latitude"]
    longitude = message_param["longitude"]
    MessageLocation.create!(message: message, title: title, address: address, latitude: latitude, longitude: longitude)
    client.reply_message(event['replyToken'], {
      type: "location",
      title: message.message_location_title,
      address: message.message_location_address,
      latitude: message.message_location_lat,
      longitude: message.message_location_long,
    })
  end

  def echo_sticker(message, event)
    package_id = event.message["packageId"]
    sticker_id = event.message["stickerId"]
    MessageLocation.create!(message: message, package_id: package_id, sticker_id: sticker_id)
    client.reply_message(event['replyToken'], {
      type: "sticker",
      packageId: message.message_sticker_package_id,
      stickerId: message.message_sticker_sticker_id,
    })
  end

  def receive_follow(message_target)
    client.push_message(
      message_target.platform_id, # userIdが入る
      {
        type: "text",
        text: "友達登録ありがとうございます！"
      }
    )
  end

  def receive_unfollow(message_target)
    message_target.blocked = true
    message_target.save!
  end

  def receive_join(message_target)
    client.push_message(
      message_target.platform_id, # groupID or roomIdが入る
      {
        type: "text",
        text: "招待ありがとうございます！"
      }
    )
  end

  def receive_leave(message_target)
    message_target.leaved = true
    message_target.save!
  end

  def client
    @client ||= Line::Bot::Client.new do |config|
      config.channel_secret = ENV["LINE_CHANNEL_SECRET"]
      config.channel_token = ENV["LINE_CHANNEL_TOKEN"]
    end
  end

  def find_or_create_message_target(event)
    # TODO message_targetを作る処理
    # event["source"]["type"]にgroupかroomかuserが入るのでそれによって、message_group, message_room, message_userを作る。カラムはできるだけ共通に設計する
  end
end
```

基本的なLineとのやりとりは上記のcontrollerのみ。以下からmodel, carrierwave, rspecの処理を抜粋して書く。


##model
```ruby:app/models/message.rb
class Message < ApplicationRecord
  delegate :value, to: :message_text, prefix: true, allow_nil: true
  delegate :url, :thumbnail_url, to: :message_image, prefix: true, allow_nil: true
  delegate :url, :thumbnail_url, to: :message_video, prefix: true, allow_nil: true
  delegate :url, :duration, to: :message_audio, prefix: true, allow_nil: true
  delegate :title, :address, :lat, :long, to: :message_location, prefix: true, allow_nil: true
  delegate :package_id, :sticker_id, to: :message_sticker, prefix: true, allow_nil: true

  belongs_to :chat
  has_one :message_text, dependent: :destroy
  has_one :message_image, dependent: :destroy
  has_one :message_video, dependent: :destroy
  has_one :message_audio, dependent: :destroy
  has_one :message_location, dependent: :destroy
  has_one :message_sticker, dependent: :destroy

  enum message_type: [:text, :image, :video, :audio, :location, :sticker]

end
```

```ruby:app/models/message_text.rb
class MessageText < ApplicationRecord
  belongs_to :message
end
```

```ruby:app/models/message_image.rb
class MessageImage < ApplicationRecord
  delegate :url, to: :value
  belongs_to :message

  mount_uploader :value, MessageImageUploader
  def thumbnail_url
    value.thumbnail.url
  end
end
```

```ruby:app/models/message_video.rb
class MessageVideo < ApplicationRecord
  delegate :url, to: :value
  delegate :url, to: :thumbnail, prefix: true

  belongs_to :message

  mount_uploader :thumbnail, MessageVideoThumbnailUploader
  mount_uploader :value, MessageVideoUploader

end
```

```ruby:app/models/message_audio.rb
class MessageAudio < ApplicationRecord
  delegate :url, to: :value
  belongs_to :message

  mount_uploader :value, MessageAudioUploader
end
```

```ruby:app/models/message_sticker.rb
class MessageLocation < ApplicationRecord
  belongs_to :message

  # latitude, longtitudeはdecimalで定義
  def lat
    latitude.to_f
  end

  def long
    longitude.to_f
  end
end
```

```ruby:app/models/message_sticker.rb
class MessageSticker < ApplicationRecord
  belongs_to :message
end
```

## Carrierwave

```ruby:app/uploaders/base_uploader.rb
class BaseUploader < CarrierWave::Uploader::Base
  if Rails.env.production?
    storage :fog
  else
    storage :file
  end

  protected
  def secure_token
    var = :"@#{mounted_as}_secure_token"
    model.instance_variable_get(var) || model.instance_variable_set(var, SecureRandom.uuid)
  end
end
```

```ruby:app/uploaders/message_image_uploader.rb
class MessageImageUploader < BaseUploader
  include CarrierWave::RMagick

  process :resize_to_limit => [1000, 1000]
  version :thumbnail do
    process :cut_out_square
    process :resize_to_limit => [500, 500]
  end
  def cut_out_square
    manipulate! do |img|
      size = [img.columns, img.rows].min
      img.resize_to_fill(size, size)
    end
  end

  def store_dir
    "images/#{model.class.to_s.underscore}"
  end

  def extension_white_list
    %w(jpg)
  end

  def filename
    "#{secure_token}.jpg" if original_filename.present?
  end
end
```

```ruby:app/uploaders/message_video_uploader.rb
class MessageVideoThumbnailUploader < BaseUploader
  process :resize_to_limit => [240, 240]

  def store_dir
    "images/#{model.class.to_s.underscore}"
  end

  def extension_white_list
    %w(jpg)
  end

  def filename
    "#{secure_token}.jpg" if original_filename.present?
  end
end
```

```ruby:app/uploaders/message_video_uploader.rb
class MessageVideoUploader < BaseUploader
  version :screenshot do
    process :screenshot
    def full_filename (*)
      "screenshot.jpg"
    end
  end

  def screenshot
    tmpfile = File.join(File.dirname(current_path), "tmpfile")

    File.rename(current_path, tmpfile)
    movie = FFMPEG::Movie.new(tmpfile)
    movie.screenshot(current_path + ".jpg")
    model.thumbnail = File.open(current_path + ".jpg")

    File.delete(tmpfile)
  end

  def store_dir
    "videos/#{model.class.to_s.underscore}"
  end

  def extension_white_list
    %w(mp4)
  end

  def filename
    "#{secure_token}.mp4" if original_filename.present?
  end
end
```

```ruby:app/uploaders/message_audio_uploader.rb
require "audioinfo"

class MessageAudioUploader < BaseUploader
  process :audio_info

  def store_dir
    "audios/#{model.class.to_s.underscore}"
  end

  def extension_white_list
    %w(m4a)
  end

  def filename
    "#{secure_token}.m4a" if original_filename.present?
  end

  def audio_info
    AudioInfo.open(current_path) do |info|
      model.duration = info.length
    end
  end
end
```

# monkey patch
AudioInfoでm4aをinitializeするとforkを使った処理を内部で実行して、forkの子プロセスが閉じた時にmysqlのコネクションもクローズしてmysql has gone awayになる。forkの実行してるメソッドの処理は今回不要のためoverrideする


```ruby:lib/monkey_patches/audioinfo.rb
require "audioinfo"
class AudioInfo
  # 中でfork処理をしてmysql has gone awayになる。
  # 大した情報とってないので、override
  def faad_info(*)
    ""
  end
end
```

# rspec
controllerのテストだけ

```ruby:spec/controllers/line_controller_spec.rb
require 'rails_helper'
require 'line/bot'

TEXT_CONTENT = {
  "events":[
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "u206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id":"325708",
        "type": "text",
        "text":"hello"
      }
    }
  ]
}.freeze

IMAGE_CONTENT = {
  "events":[
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "u206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id":"325709",
        "type": "image"
      }
    }
  ]
}.freeze

VIDEO_CONTENT = {
  "events":[
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "u206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id":"325710",
        "type": "video"
      }
    }
  ]
}.freeze

AUDIO_CONTENT = {
  "events":[
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "u206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id":"325711",
        "type": "audio"
      }
    }
  ]
}.freeze

LOCATION_CONTENT = {
  "events":[
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "u206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id":"325712",
        "type": "location",
        "title": "my location",
        "address": "tokyo",
        "latitude": 35.65910807942215,
        "longitude": 139.70372892916203
      }
    }
  ]
}.freeze

STICKER_CONTENT = {
  "events":[
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "message",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "u206d25c2ea6bd87c17655609a1c37cb8"
      },
      "message": {
        "id":"325709",
        "type": "sticker",
        "packageId": "1",
        "stickerId": "1"
      }
    }
  ]
}.freeze

FOLLOW_EVENT = {
  "events":[
    {
      "replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
      "type": "follow",
      "timestamp": 1462629479859,
      "source": {
        "type": "user",
        "userId": "u206d25c2ea6bd87c17655609a1c37cb8"
      }
    }
  ]
}.freeze

PROFILES_CONTENT = <<-EOS.freeze
{
  "displayName":"BOT API1",
  "userId":"u206d25c2ea6bd87c17655609a1c37cb8",
  "pictureUrl":"#{Line::Bot::API::DEFAULT_ENDPOINT}/message/325709/content",
  "statusMessage":"Hello, LINE!"
}
EOS

IMAGE_FILE = Rack::Test::UploadedFile.new(File.join(Rails.root, 'spec', 'fixtures', 'files', 'sample.jpg'), "image/png")
VIDEO_FILE = Rack::Test::UploadedFile.new(File.join(Rails.root, 'spec', 'fixtures', 'files', 'sample.mp4'), "video/mp4")
AUDIO_FILE = Rack::Test::UploadedFile.new(File.join(Rails.root, 'spec', 'fixtures', 'files', 'sample.m4a'), "audio/m4a")

WebMock.allow_net_connect!

RSpec.describe LineController, type: :controller do
  describe "POST #echo" do
    before(:each) do
      request.env["HTTP_ACCEPT"] = 'application/json'
      request.env["CONTENT_TYPE"] = 'application/json'
      allow_any_instance_of(Line::Bot::Client).to receive(:validate_signature).and_return(true)
      stub_request(:get, Addressable::Template.new("#{Line::Bot::API::DEFAULT_ENDPOINT}/profile/{mids}")).to_return { { body: PROFILES_CONTENT, status: 200} }
      stub_request(:post, Addressable::Template.new("#{Line::Bot::API::DEFAULT_ENDPOINT}/message/push")).to_return { |request| {body: request.body, status: 200} }
      stub_request(:post, Addressable::Template.new("#{Line::Bot::API::DEFAULT_ENDPOINT}/message/reply")).to_return { |request| {body: request.body, status: 200} }
      FakeWeb.register_uri(:get, "#{Line::Bot::API::DEFAULT_ENDPOINT}/message/325709/content", body: IMAGE_FILE, 'Content-Type' => "image/png")
    end

    it "request message text" do
      expect do
        post :callback, params: TEXT_CONTENT
      end.to change(MessageText, :count).by(1)
    end

    it "request message image" do
      expect do
        post :callback, params: IMAGE_CONTENT
      end.to change(MessageImage, :count).by(1)
    end

    it "request message video" do
      FakeWeb.register_uri(:get, "#{Line::Bot::API::DEFAULT_ENDPOINT}/message/325710/content", body: VIDEO_FILE, 'Content-Type' => "video/mp4")
      expect do
        post :callback, params: VIDEO_CONTENT
      end.to change(MessageVideo, :count).by(1)
    end
    it "request message audio" do
      FakeWeb.register_uri(:get, "#{Line::Bot::API::DEFAULT_ENDPOINT}/message/325711/content", body: AUDIO_FILE, 'Content-Type' => "audio/m4a")
      expect do
        post :callback, params: AUDIO_CONTENT
      end.to change(MessageAudio, :count).by(1)
    end

    it "request message location" do
      expect do
        post :callback, params: LOCATION_CONTENT
      end.to change(MessageLocation, :count).by(1)
    end

    it "request message sticker" do
      expect do
        post :callback, params: STICKER_CONTENT
      end.to change(MessageSticker, :count).by(1)
    end

    it "request follow event" do
      expect do
        post :callback, params: FOLLOW_EVENT
      end.to change(Message, :count).by(Constants::INITIAL_MESSAGES.size)
    end
  end
end
```

factorygirlの音声、動画、画像はこんな感じで渡せば作ってくれる。

```ruby:spec/factories/message_videos.rb
FactoryGirl.define do
  factory :message_video do
    value { Rack::Test::UploadedFile.new(File.join(Rails.root, 'spec', 'fixtures', 'files', 'sample.mp4')) }
    message { FactoryGirl.create :message }
  end
end
```


```yaml:circleci.yml
dependencies:
  pre:
    - sudo apt-get update
    - sudo apt-get install build-essential automake autoconf zlib1g-dev libtool libx264-dev yasm
    - wget http://ffmpeg.org/releases/ffmpeg-2.6.tar.bz2
    - tar xjf ffmpeg-2.6.tar.bz2
    - cd ffmpeg-2.6 && ./configure --enable-libx264 --enable-gpl && make && sudo make install
  cache_directories:
    - ffmpeg-2.6
```

今回のラインのAPIドキュメントはかなりわかりやすくなってます。
ただ、音声の長さとか動画のスクショはLine内部で作ってもらえたらより実装は楽になったのになーと思いました。
https://devdocs.line.me/ja/#send-message-object