ifttt
RaspberryPi
Line
selenium-webdriver
GoogleHome

Google Homeで自分のLINEアカウントからメッセージを送れるようにした

はじめに

今までGoogle HomeでLINEを使用する方法は、LINE NotifyやLINE BOT等、自分のアカウント以外を利用する必要がありました。
今回は、ラズパイ上で開いたchromiumブラウザのLINE拡張機能を使うことでGoogle Homeへの音声命令でLINE NotifyやLINE BOTではなく自分のアカウントからLINEを送信できるようにしました。
今回の実装ではあらかじめ決めていた相手にしか送信できず、そんなに万能ではないので注意してください。


上の動画は、リモートデスクトップで見てみたRaspberry Piの処理になります。途中の読み込みがスムーズではなかったため送信まで時間がかかっています。私の環境では平均30-40秒程度でLINEメッセージが送信されます。

筆者について

今回の記事が初めてQiitaに投稿になります。
Node.jsを使うのもこの実装が初めてです。
いろいろとわかりづらい箇所やとおかしな実装があると思われますのでご指摘いただければうれしいです。

処理の流れ

処理の流れは以下になります。
workflow.png

Google HomeにLINEで送信したい内容を話しかけると、IFTTTのWebhooks, Raspberry PiのNode.jsを経由してRaspberry Pi上のChromiumブラウザのLINE拡張機能を自動操作してメッセージを送信する流れとなっています。拡張機能を自動操作する部分は、SeleniumというWebページのテスト自動化ツールを用いて実装しています。Seleniumについてはもう少し後で説明します。

開発環境

開発環境は以下になります。Raspberry PiもNode.jsのどちらも使うのが初めてだったため、有名なgoogle-home-notifierのコードを参考に実装しました。

Google Home
IFTTT
Raspberry Pi3 Model B
Node.js
Selenium-webdriver
Chromium(LINE拡張機能)

事前準備

Node.jsのインストール

Node.jsでの実装部分は、google-home-notifierの実装を参考にしているため、導入までの流れはほとんど同じです。Raspberry Pi上にgoogle-home-notifierが動作する環境が整っていれば大丈夫です。まだ、google-home-notifierを導入していないのであれば、以下の記事を参考にgoogle-home-notifierを導入して下さい。

Chromiumの設定

ChromiumはRaspberry Piで動作するChromeとほとんど同じブラウザです。
自動操作するためにChromiumにLINE拡張機能を事前に追加しておきます。
ChromiumのLINE拡張機能は、以下のURLから追加できます。

新しいPC(Raspberry Pi)のLINE拡張機能からログインすると最初の1回だけ本人確認をする必要があります。そのため、まずは一度ログインして本人確認を行ってください。

続いて、SeleniumでChromiumを操作する際に必要なProfileのpathを確認します。
ProfileはChromiumのどんな拡張機能を使用しているか等のユーザ設定が保存されているディレクトリになっています。SeleniumでChromiumを操作する際にこのProfileのpathを指定してあげないと、新しいユーザとしてChromiumを操作することになってしまいます。LINE拡張機能を追加されたChromiumのURLに[chrome://version/]と入力して表示されるページでProfile path(プロフィールパス)を確認してメモしておいてください。このメモしたpathはnode.jsの実装部分で使用します。
もし、普段使っているProfileのChromiumが勝手に操作されるのは気持ち悪いと感じる人は、新しいディレクトリを用意してそちらをProfileとして設定することで、今回のプログラムを回す際には新しいProfileを使うことができます。その際は、新しいProfileを用いたChromiumを起動してLINE拡張機能を追加、本人確認をしてそのProfileのpathをメモしてください。

実装

IFTTT側

google assistantを「THIS」にしてWebhooksを「THAT」にして設定しました。
まずは、THISの部分を見ていきましょう。
GoogleAssistantのtriggerは"Say a phase with a text ingredient"を使用しています。
送信した内容が自分でもわかるようにGoogleAssistantのresponseにメッセージを復唱させています。
自分の体感では、「LINEで○○を送信」よりも「LINEで送信、〇〇」の方が認識精度が高いと感じました。

続いてTHATの部分を見ていきます。
Webhooksのactionは"Make a web request"に設定しました。
URL部分にNode.jsで建てたサーバーのURLを張ります。
google-home-notifierの設定と同様に以下の画像の様に設定しました。

例:Google Homeに対して、「OK google, LINEで送信、こんにちは」と声をかけると「こんにちは」を格納したリクエストがURLに投げられます。

Node.js

Raspberry Piに建てるサーバー部分の実装になります。
処理の流れは「IFTTTのWebhooksによるメッセージを含んだリクエストを受け取り、ChromiumのLINE拡張機能にアクセスしてメッセージを送信」という感じです。
Node.js触ったのが今回が初めてだったので、サーバーの部分はgoogle-home-notifierのexample.jsをほとんどコピペして、「テキストをGoogle Homeに喋らせる」部分を「テキストをLINE拡張機能で送信する」に書き換えることで実装しました。

ChromiumのLINE拡張機能にアクセスする部分はSeleniumを使って書きました。
Seleniumとは以下のリンクにあるようにWebページのテスト自動化ツールで、JavaScriptの他にpythonやRubyでも使うことができます。

JavaScriptでもDOM操作ができますが、今回操作するのはWebサイトではなくChromiumの拡張機能であるためChromiumブラウザ経由でないと使えません。SeleniumはWebDriverと呼ばれる物を用いることでChrome(Chromium)やFireFox, IEといったブラウザを経由して操作することができます。
WebDriverはOS,使用するブラウザごとに異なるものを用意する必要があります。Raspberry Piのchromiumで使用可能なWebDriverに関する記事が英語でしたが以下の掲示板にまとまっていました。

この掲示板に書かれていることを簡単にまとめると
① 以下のリンクからWebDriverであるchromedriverがパッケージ化された.debファイルをダウンロードする。

dpkgコマンドでダウンロードした.debファイルをインストールする。(私は失敗しましたが、.debファイルをダブルクリックすることでもインストールできるようです。)

③通常は/usr/lib/chromium-browser/にchromedriverがインストールされるはずなのでpathを通すか、ソースコードと同じディレクトリに移動させる。

といった感じです。この流れに沿ってインストールしてください。

続いて、IFTTTから受け取ったテキストをSeleniumを使ってLINE拡張機能で送信する部分の実装の解説をします。Seleniumの各関数の使い方は以下のページがとても参考になりました。

また、Seleniumライブラリは以下のコマンドで取得してください。

$ npm -g install selenium-webdriver

では、送信部分の解説をしていきます。

//target_nameはLINEで表示される送り先の名前
//sent_textは送りたいメッセージ
function send_message(target_name, sent_text){

    var By = webdriver.By;
    var Key = webdriver.Key;
    var driver;

  //拡張機能のURL
    var line_url = "chrome-extension://ophjlpahpchlmihnnnihgmmeilfjmjjc/index.html";
  //LINE拡張機能にログインする際のメールアドレス
    var email = "hogehoge.com";
  //LINE拡張機能にログインする際のパスワード
    var pwd = "hogehoge";
    //ページ遷移時のどれくらい待つかのタイムアウト値
    var WAIT_MSEC = 20000;
  //処理をどれくらい止めるかのタイムアウト値
    var SLEEP_MSEC = 10000;

あらかじめ、上記のURLでLINE拡張機能のページに飛べるか確認してください。
また、メールアドレスとパスワードは自分が使っている物を設定してください。
WAIT_MSECは、ページ遷移時の待機時間のタイムアウト値を表しています。この時間が短すぎると少し処理が重くなった時にすぐタイムアウトしてしまうため、余裕をもって20秒の設定をしています。
SLEEP_MSECはスリープ時間を定義しています。Pythonのsleep関数のような処理をする際のスリープ時間を表しています。

  //自分のprofileを使用してブラウザを開くための設定
    var options = new chrome.Options;
    options.addArguments("--user-data-dir=your/profile/path")

上記の部分では、Chromiumを開く時に用いるProfileを設定しています。Chromiumの設定のyour/profile/pathの部分の代わりに事前準備でメモしたProfileを設定してください。

  //ブラウザを立ち上げる
    var driver = new webdriver.Builder()
    .withCapabilities(webdriver.Capabilities.chrome())
    .setChromeOptions(options)
    .build();
    //URLにアクセスする
    driver.get(line_url);
    //LINE画面が読み込まれるまで待つ
    driver.wait(webdriver.until.elementLocated(By.id('line_login_email')), WAIT_MSEC);
    //ログイン情報を入力する
    driver.findElement(By.id('line_login_email')).clear();
    driver.findElement(By.id('line_login_email')).sendKeys(email);
    driver.findElement(By.id('line_login_pwd')).clear();
    driver.findElement(By.id('line_login_pwd')).sendKeys(pwd);
    //ログインボタンをクリックする
    driver.findElement(By.id('login_btn')).click();
    //チャットルームが開かれるまで待つ   
    driver.wait(webdriver.until.elementLocated(By.xpath('//*[text()=\''+target_name+'\']')),WAIT_MSEC);


指定したProfileでChromiumを起動してLINE拡張機能にアクセスします。

    driver.wait(webdriver.until.elementLocated((By.id('finish'))),SLEEP_MSEC)
    .then(_ => driver.findElement(By.xpath('//*[text()=\''+target_name+'\']')).click(),
     e => driver.findElement(By.xpath('//*[text()=\''+target_name+'\']')).click());
    driver.findElement(By.xpath('//*[text()=\''+target_name+'\']')).click();

上記の部分は、Pythonでいうsleep関数の処理を行い、SLEEP_MSECミリ秒時間のsleepをした後に送信先であるtarget_nameをクリックしています。ここで少し待機しないと、何度か正常に処理が行われない事があったためこの処理を加えました。Node.jsにはpythonのsleep関数のような物がないようなので、苦肉の策としてこのような記述を行っています。処理の流れとしては、
①wait関数を用いて、あるはずのないidの要素が現れるまで待機する。
②当然見つからずwaitのタイムアウト値を超えるため、その際に呼ばれるeの部分にtarget_nameをクリックする処理を行わせる。
となっています。
もっとスマートな記述の仕方があると思うのでだれか教えてください。m(__)m

    //トーク画面が開かれるまで待ち、クリックする
    driver.wait(webdriver.until.elementLocated(By.id('_chat_room_input')),WAIT_MSEC);
    driver.findElement(By.id('_chat_room_input')).click();
    //空白を除去したテキストを入力して送る
    driver.findElement(By.id('_chat_room_input')).sendKeys(sent_text.replace(/ /g, ""));
    driver.findElement(By.id('_chat_room_input')).sendKeys(Key.ENTER);

上記の処理ではトーク画面が表示された後、送信する内容であるsent_textを入力してEnterで送信しています。sent_textには、IFTTTから受け取る文字列である単語間に空白が生じてしまっています。そのため、replace(/ /g, "")によって空白を消去してから入力させています。

LINE拡張機能での送信方法がAlt+Enterの場合はEnterで送信できるように設定を変更するか、コードの部分を変えて見てください。

    //テキストが送信されるまでSLEEP_MSECミリ秒待ってからブラウザを閉じる
    driver.wait(webdriver.until.elementLocated((By.id('finish'))),SLEEP_MSEC)
    .then(_ => driver.close(), e => driver.close());
    driver.quit();
}

上記の部分ではテキストで無事に送信されるまでsleepしてからChromiumブラウザを閉じています。Chromiumを閉じずにLINE拡張機能にログインしっぱなしだと勝手に既読がついてしまう恐れがあると思ったので、テキスト送信する度に閉じる設計にしました。

LINE送信部分であるsend_message関数を含めたソースコードを以下にまとめます。
現状の実装では、あらかじめ設定した「りんな」にしか、メッセージは送れません。

var express = require('express');
var ngrok = require('ngrok');
var bodyParser = require('body-parser');
var webdriver = require('selenium-webdriver');
var chrome = require('selenium-webdriver/chrome');
var app = express();

const serverPort = 8095; // default port
var urlencodedParser = bodyParser.urlencoded({ extended: false });

var name = "りんな"     

app.post('/line', urlencodedParser, function (req, res) {

  if (!req.body) return res.sendStatus(400)
  console.log(req.body);

  var text = req.body.text;

  if (text){
    try {
      send_message(name, text);
      res.send('LINE will send: ' + text + '\n');
    } catch(err) {
      console.log(err);
      res.sendStatus(500);
      res.send(err);
    }
  }else{
    res.send('Please POST "text=Hello Google Home"');
  }
})

app.get('/line', function (req, res) {

  console.log(req.query);

  var text = req.query.text;

  if (text) {
    try {
      send_message(name,text);
      res.send('LINE will send: ' + text + '\n');
    } catch(err) {
      console.log(err);
      res.sendStatus(500);
      res.send(err);
    }
  }else{
    res.send('Please GET "text=Hello+Google+Home"');
  }
})

app.listen(serverPort, function () {
  ngrok.connect(serverPort, function (err, url) {
    console.log('Endpoints:');
    console.log('    ' + url + '/line');
    console.log('GET example:');
    console.log('curl -X GET ' + url + '/line?text=Hello+Google+Home');
      console.log('POST example:');
    console.log('curl -X POST -d "text=Hello Google Home" ' + url + '/line');
  });
})



function send_message(target_name, sent_text){

    var By = webdriver.By;
    var Key = webdriver.Key;
    var driver;

    var line_url = "chrome-extension://ophjlpahpchlmihnnnihgmmeilfjmjjc/index.html";
    var email = "hogehoge.com";
    var pwd = "hogehoge";
    var WAIT_MSEC = 20000;
    var SLEEP_MSEC = 10000;
    var options = new chrome.Options;
    options.addArguments("--user-data-dir=your/profile/path")

    var driver = new webdriver.Builder()
    .withCapabilities(webdriver.Capabilities.chrome())
    .setChromeOptions(options)
    .build();

    //URLにアクセスする
    driver.get(line_url);
    //LINE画面が読み込まれるまで待つ
    driver.wait(webdriver.until.elementLocated(By.id('line_login_email')), WAIT_MSEC);
    //ログイン情報を入力する
    driver.findElement(By.id('line_login_email')).clear();
    driver.findElement(By.id('line_login_email')).sendKeys(email);
    driver.findElement(By.id('line_login_pwd')).clear();
    driver.findElement(By.id('line_login_pwd')).sendKeys(pwd);
    //ログインボタンをクリックする
    driver.findElement(By.id('login_btn')).click();
    //チャットルームが開かれるまで待つ   
    driver.wait(webdriver.until.elementLocated(By.xpath('//*[text()=\''+target_name+'\']')),WAIT_MSEC);
    driver.wait(webdriver.until.elementLocated((By.id('finish'))),SLEEP_MSEC)
    .then(_ => driver.findElement(By.xpath('//*[text()=\''+target_name+'\']')).click(),
     e => driver.findElement(By.xpath('//*[text()=\''+target_name+'\']')).click());
    driver.findElement(By.xpath('//*[text()=\''+target_name+'\']')).click();
    //チャットルームが開かれるまで待ち、クリックする
    driver.wait(webdriver.until.elementLocated(By.id('_chat_room_input')),WAIT_MSEC);
    driver.findElement(By.id('_chat_room_input')).click();
    //空白を除去したテキストを入力して送る
    driver.findElement(By.id('_chat_room_input')).sendKeys(sent_text.replace(/ /g, ""));
    driver.findElement(By.id('_chat_room_input')).sendKeys(Key.ENTER);
    //テキストが送信されるまで少し待ってからブラウザを閉じる
    driver.wait(webdriver.until.elementLocated((By.id('finish'))),SLEEP_MSEC)
    .then(_ => driver.close(), e => driver.close());
    driver.quit();
}

今後の展望

今回の実装では、あらかじめ決めておいた相手にしかメッセージを送ることができません。もっと便利に利用するためには複数の相手にメッセージを送る機能が必要だと思います。次のステップとしては、IFTTTでトリガーワードを「LINEで〇に△を送信」として、「〇に△」の部分をnode.jsで受け取り、送信先を〇で送信内容を△というようにnode.js上で文字列を分割することで実装しようかなと考えています。