Rails
linebot
Clova

[LineBootAwards2018] linebotとclovaを連携させて見守りアプリを作った。

アプリ名

IMAGE ALT TEXT HERE
linebot: ドア君
clovaskill: ドア君

説明

ただいまの言葉をきっかけに親からの伝言を子供へ伝えます。
忙しくて電話もできない、手紙も書く時間がない親に変わってテキスト形式で子供への伝言を預かります。
子供の周りに起きている事件の情報、洗濯物の天敵の雨の情報、よく遊ぶ子供が避けたいインフルエンザの情報を親に届けます。

紹介動画

https://youtu.be/G19BNJIt0co

エントリーページ

https://www.line-community.me/awards/workdetail/5bbd650d851f745ac3022468

 機能概要

  • 伝言板機能
    • ドア君アカウントに対してメッセージを登録
    • clovaでスキルを起動 -> 伝言板を読み上げ・メッセージプッシュが行われる
      • line_user_idにより紐付けを行った。
  • お天気プッシュ機能
  • 不審者プッシュ機能
    • ドア君アカウントに対してメッセージで不審者情報のプッシュ通知をする地区を登録
    • 警視庁ホームページより、情報を取得し、プッシュ通知
  • インフルエンザプッシュ機能
    • GoogleTrendsより、「インフルエンザ」の検索結果により流行を予測

 使用技術

  • 使用言語: Ruby
  • フレームワーク: RubyOnRails
  • デプロイ: heroku
  • linebot
  • clova

Messaging Api

rubyなので、line-bot-apigemを利用

gem 'line-bot-api'

https://developers.line.me/ja/docs/messaging-api/

プッシュ通知

require 'line/bot'
require 'dotenv'

class LineBotClient
  # OPTIMIZE このクラスにLineBotへのリクエストをまとめる

  # to: userId、groupId、またはroomId
  # https://developers.line.me/ja/reference/messaging-api/#anchor-0c00cb0f42b970892f7c3382f92620dca5a110fc
  def pushMessage(type='text', text="", to)
    message = {
      type: type,
      text: text
    }
    client.push_message(to, message)
  end

  private

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

第一引数: プッシュメッセージのタイプ
第二引数: 送信するテキスト情報
第三引数: userId、groupId、またはroomId

line_bot_client = LineBotClient.new
line_bot_client.pushMessage('text', text, to)

clovaスキル

rubyに対応しているライブラリはないので、直接CEK_APIを叩く
https://clova-developers.line.me/guide/#/CEK/References/CEK_API.md

clovaの応答

@shouldEndSessionの値がfalseだとクローバのスキルが終了せずに再度待ち状態となる。
だが、@shouldEndSessionの値がfalseでもvoice_messageが存在しない場合は、セッションが終了する仕様であった。

class ClovaController < ApplicationController
  require 'line/bot'
  before_action :set_clova, only: [:callback]
  before_action :set_shouldEndSession, only: [:callback]

  def callback
    @voice_message = "おかえりなさい!"

    if @shouldEndSession
      # ラインボットへ通知を送る
      send_push_message

      # @voice_messageの設定
      set_voice_message
    end

    render 'clova/callback', formats: 'json', handlers: 'jbuilder'
  end

  private

  def set_shouldEndSession
    # スキルの起動時のみfalseとする
    slots = params['request']['intent']['slots']
    @shouldEndSession = slots.nil? ? false : true
  end

  def send_push_message
  ...
  end

  def set_voice_message
  ...
  end
end

callback.json.jbuilder

json.version "1.0"
json.response do |response|
  response.outputSpeech do |outputSpeech|
    outputSpeech.type "SimpleSpeech"
    outputSpeech.values do |values|
      values.type "PlainText"
      values.lang "ja"
      values.value @voice_message
    end
  end
  response.card {}
  response.directives []
  response.shouldEndSession @shouldEndSession
end

お天気情報取得

OpenWeatherMapApiを利用。無料プランで、3時間毎のお天気情報が取得できる。

https://openweathermap.org/

サンプル

https://samples.openweathermap.org/data/2.5/forecast?id=524901&appid=b1b15e88fa797225412429c1c50c122a1

require "json"
require "open-uri"

class OpenWeatherMap
  BASE_URL = "https://api.openweathermap.org/data/2.5/forecast".freeze
  TOKYO_CITY_ID = '1850147'

  # tokyo: key='id', val='1850147'
  # akashi: key='q', val='Akashi'
  def self.get(key = 'id', val = TOKYO_CITY_ID)
    url = BASE_URL + "?#{key}=#{val}&APPID=#{ENV['Open_Weather_Map_API_KEY']}"

    puts "Request method:GET, URL:#{url}"
    json_response = open(url)
    JSON.parse(json_response.read)
  end
end
tokyo_city_id = '1850147'
tokyo_weather_infos = OpenWeatherMap.get('id', tokyo_city_id)

不審者情報

Mechanizeでのスクレイプ。
1日毎に情報が更新されるため、1日に一回クーロンで実行。

namespace :patrol do
  desc "東京都の不審者情報取得"
  task :tokyo_police_page => :environment do
  SLEEP_TIME = 1.second.freeze

    puts "Start scrape..."
    agent = Mechanize.new
    top_page = agent.get("http://www.keishicho.metro.tokyo.jp/kurashi/higai/kodomo/fushin/index.html")
    sleep(SLEEP_TIME)
    detail_urls = top_page.search('.norcor').search('a').map{|e| e.get_attribute(:href) }
    detail_urls.each_with_index do |detail_url, i|
      sleep(SLEEP_TIME)
      # only scrape newest day
      break if i > 0

      # move to detail page
      detail_page = top_page.link_with(href: detail_url).click
      city_infos = detail_page.search('tr')
      city_infos.each do |city_info|
        # input suspicious person infomation
        city_name = city_info.children.search('th > p').children.first.text
        suspicious_person_info_text = city_info.children.search('td > p').first.children.map{ |a| a.text }.reject(&:empty?).join('\n')
        tx = city_info.children.search('ul > li').first&.children&.map{ |a| a.text }&.reject(&:empty?)&.join('\n')
        suspicious_person_info_text += "\n" + tx if tx

        if city = City.find_by(name: city_name)
          puts "find #{city_name}"
          city.suspicious_person_infos.find_or_create_by(
            text: suspicious_person_info_text,
            source_url: detail_url,
            published_at: Time.now.beginning_of_day)
        else
          puts "Error: Not found city name #{city_name}"
        end
      end
    end
    puts "Finish scrape.\n"

    puts "Start send message..."
    send_alart_push_message()
    puts "Finish send message."
  end

  def send_alart_push_message()
    SuspiciousPersonInfo.tell_infos.each do |line_user_id, cities|
      text = create_message_text(cities).chomp
      response = send_message_text(text, line_user_id.to_s)
      puts "Sent message. line_user_id:#{line_user_id} response:#{response}"
    end
  end

  def send_message_text(text, to)
    @line_bot_client ||= LineBotClient.new
    @line_bot_client.pushMessage('text', text, to)
  end

  def create_message_text(cities)
    # TODO 不審者情報が見やすいようなページを作成
    # tokyo_police_page = "http://www.keishicho.metro.tokyo.jp/kurashi/higai/kodomo/fushin/index.html"
    <<-EOS
<不審者情報>
#{cities.map(&:name).join(', ')} にて不審者が確認されました。
EOS
  end
end

インフルエンザ情報

取得したCSVをManagementページで追加すると、CSVからインフルエンザのトレンド情報を取得する。そのトレンドからインフルエンザを予測。

現状は、自動でCSVを取得する部分が未完成。

require 'csv'
class GoogleTrendsManager
  def initialize(a = nil, b = nil)
    @csv_file = nil

    if a.class == ActionDispatch::Http::UploadedFile && b == nil
      import_csv_by_file(a)
    elsif a.class == String && b.class == String
      import_csv_by_crowl(a, b)
    else
      raise "Unexpected params."
    end
  end

  def get_trends
    return unless @csv_file
    trends = {}

    CSV.foreach(@csv_file.path, headers: false).with_index do |row, i|
      next if i < 3

      time, score = row
      trends[time] = score
    end

    trends
  end

  private

  def import_csv_by_file(file)
    @csv_file = file
  end

  def import_csv_by_crowl(q='', geo='JP')
    # TODO Seleniumでのクローリング -> CSVファイル取得
    ...
  end
end

参考

https://developers.line.me/ja/docs/
https://openweathermap.org/
http://www.keishicho.metro.tokyo.jp/
https://qiita.com/4geru/items/7fdd418ce8c1059001c6