Edited at

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

More than 3 years have passed since last update.

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