2016年10月2日現在のLINE Messaging APIを送信、受信のAPIを大体試します。
Echo BotをRuby, Railsで実装し、全てのメッセージタイプの保存してエコーする実装を、テストまで含め紹介します。
** スキーマなどの詳細は追って追記します
** Template Messageも追って追記します
#前提
バリデーションやエラーハンドリングなど例外的な処理は書きません。
動画はmp4、音声はm4a、画像はjpgの前提とする。
#使ったライブラリ
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
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
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
class MessageText < ApplicationRecord
belongs_to :message
end
class MessageImage < ApplicationRecord
delegate :url, to: :value
belongs_to :message
mount_uploader :value, MessageImageUploader
def thumbnail_url
value.thumbnail.url
end
end
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
class MessageAudio < ApplicationRecord
delegate :url, to: :value
belongs_to :message
mount_uploader :value, MessageAudioUploader
end
class MessageLocation < ApplicationRecord
belongs_to :message
# latitude, longtitudeはdecimalで定義
def lat
latitude.to_f
end
def long
longitude.to_f
end
end
class MessageSticker < ApplicationRecord
belongs_to :message
end
Carrierwave
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
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
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
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
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する
require "audioinfo"
class AudioInfo
# 中でfork処理をしてmysql has gone awayになる。
# 大した情報とってないので、override
def faad_info(*)
""
end
end
rspec
controllerのテストだけ
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の音声、動画、画像はこんな感じで渡せば作ってくれる。
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
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