31
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-10-02

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

#前提
バリデーションやエラーハンドリングなど例外的な処理は書きません。
動画はmp4、音声はm4a、画像はjpgの前提とする。

#使ったライブラリ

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

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

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
app/models/message_text.rb
class MessageText < ApplicationRecord
  belongs_to :message
end
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
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
app/models/message_audio.rb
class MessageAudio < ApplicationRecord
  delegate :url, to: :value
  belongs_to :message

  mount_uploader :value, MessageAudioUploader
end
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
app/models/message_sticker.rb
class MessageSticker < ApplicationRecord
  belongs_to :message
end

Carrierwave

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
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
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
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
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する

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

rspec

controllerのテストだけ

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の音声、動画、画像はこんな感じで渡せば作ってくれる。

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
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

31
32
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
32

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?