自分用LINE botで自宅PCをwake on lan



f:id:eblbl:20170721221620p:plain
LINEて、便利ですよね。色々不満もあるけど結局皆使ってるんです。
そんなLINEのbotを自分で作れるようになったので、何か便利なモノを作ろうと思った第1弾がタイトルの通りです。
LINEでbotに話しかけるとPCが立ち上がっているかどうかが分かって、起動させることが出来ます。


LINE botはHerokuで動かします。LINEからコマンドを受け取ったHeroku上のJava Webアプリが自宅サーバーにアクセスして、CGIで自宅LAN内のPCを操作します。セキュリティ的なアレはアレです。ご了承下さい。


解説ですが、作ったあとに改めてやり直していないので、抜けている説明もあるかもしれません。うまく行かなかったらコメント下さい。あと長いです。


環境:
自宅PC: Windows 7 x64
サーバー: Raspberry Pi 2
自宅アドレス: .tkドメイン


手順:
自宅サーバーのセットアップ
② LINE botアカウントの作成
③ Herokuアカウントの作成
Javaアプリを作成しデプロイ


では、作り方です。
自宅サーバーのセットアップ
前回の記事
eblbl.hatenablog.com
で使ったRaspberry PiApacheサーバーを立てて、CGIで自宅PCの操作ができるようにします。


1. PCのIPアドレスを固定
(ググれば分かる)
192.168.xxx.xxxに固定したとします。


2. pingに応答するように設定
Windowsはデフォルトでpingに応答しないので、設定します。
https://www.google.co.jp/search?q=windows+ping+%E5%BF%9C%E7%AD%94
(ググれば分かる)


3. Raspberry Piからpingしてみる

$ ping -c 1 -W 1 192.168.xxx.xxx
PING 192.168.xxx.xxx (192.168.xxx.xxx) 56(84) bytes of data.
64 bytes from 192.168.xxx.xxx: icmp_seq=1 ttl=128 time=0.*** ms

--- 192.168.xxx.xxx ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.***/0.***/0.***/0.000 ms

起動していますね。


4. PCがwake on lanで起動するように設定
Windowswolで起動するようにします。シャットダウンからでもスリープからでも復帰できるようにしておきましょう。
www.atmarkit.co.jp


5. Raspberry Piからwolしてみる
wakeonlanをインストールします

$ sudo apt-get install wakeonlan

PCのMACアドレスを記録して、XX:XX:XX:XX:XX:XXとします。
PCをスリープもしくはシャットダウン状態にして、Raspberry Piからwolを送信します。

$ wakeonlan XX:XX:XX:XX:XX:XX

PCが起動します。


6. Apacheサーバーの設定
apache2をインストールします

$ sudo apt-get install apache2

完了すると、デフォルトのサイトが立ち上がっているはずです。
Raspberry Piで確認してみます。

$ curl localhost


<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Apache2 Debian Default Page: It works</title>

...

LAN内のPCやスマホからも確認して下さい。Apacheのデフォルトページが表示されているはずです。


7. サイトの作成
デフォルトサイトをコピーして、今回使うサイトを作成します。

$ sudo cp /etc/apache2/sites-available/000-default.conf /etc/apache2/sites-available/wake-up-PC.conf

コピー先のファイル名は何でも大丈夫です。.confファイルを編集します。

$ sudo vim /etc/apache2/sites-available/wake-up-PC.conf

ServerNameのコメントアウトを外し、.tkドメインを設定します。

        ServerName [.tkドメイン]

DocumentRootを変えたい場合は変えてください。
次の設定を追加します。

        <Directory [ドキュメントルート]>
                SetEnv HTTP_X_FORWARDED_PROTO http
                SetEnv REAL_HOST_NAME [.tkドメイン]
                Options Indexes FollowSymLinks ExecCGI
                AllowOverride All
                Require all granted
        </Directory>

        <FilesMatch "(check|wake)">
                SetHandler cgi-script
        </FilesMatch>

CGIの実行許可と、この後作成する"check"、"wake"ファイルの実行許可をしています。拡張子が無いファイルを実行許可したい場合は、このように個別にファイルを指定する必要があります。正規表現が使えます。


テスト用のindex.htmlを置いておきましょう。

$ echo "hello" > [ドキュメントルート]/index.html

デフォルトサイトを無効化し、今作ったサイトを有効化します。

$ sudo a2dissite 000-default
$ sudo a2ensite wake-up-PC
$ sudo service apache2 reload

何か追加でコマンドを実行しろというメッセージが出たら従ってください。
サイトが立ち上がっているかをチェックします。

$ curl localhost
hello


CGIを書きます。今回はシェルスクリプトです。次のようなファイルをドキュメントルートに作成してください。
file: check

#!/bin/bash

echo "Content-type: text/html"
echo

ping -c 1 -W 1 192.168.xxx.xxx > /dev/null 2>&1
if [ $? -eq 0 ]; then
        echo "up"
else
        echo "down"
fi

file: wake

#!/bin/bash

echo "Content-type: text/html"
echo

wakeonlan XX:XX:XX:XX:XX:XX > /dev/null 2>&1

echo "ok"

実行属性を与えます。

$ chmod +x [ドキュメントルート]/check
$ chmod +x [ドキュメントルート]/wake

CGIを使うためのmodを有効化します。

$ sudo a2enmod cgi
$ sudo service apache2 reload

LAN内のPCやスマホから、[Raspberry Piのアドレス]/checkや/wakeにアクセスしてみてください。PCの電源状態の確認と起動ができるはずです。
自宅のルーターを設定して、外からアクセスできるようにしてください。ポート変換の設定方法は「[ルーター名] ポート変換」などとググれば出てきます。
外部から特定ポート(:8080など)へのアクセスを、Raspberry PiIPアドレスの:80にリダイレクトします。
設定後、外部の回線から/checkや/wakeへのアクセスを確認してください。これだけでも便利ですね。


② LINE botアカウントの作成
とりあえず、これに従って「3. LINE Developers の設定」まで作業を進めてください。
bita.jp

③ Herokuアカウントの作成
www.heroku.com
herokuでアカウントを作ってください。まだappは作らなくて大丈夫です。

Javaアプリを作成しデプロイ
GitHubアカウントがない場合には作成してください。
github.com
今回は、こちらのアプリを使わせていただきます。
github.com
これをForkあるいはCloneして、自分のリポジトリを作ります。
まずは試しに、作ったポジトリの f:id:eblbl:20170722112213p:plain ボタンを押してください。
Herokuの設定画面が開きます。
・「App Name (optional)」とConfig Variablesの「* APP_NAME」には同じものを入力してください。
LINE Developersから、作成したLINE botアカウントの「Channel Secret」および「Channel Access Token」をConfig Variablesに設定してください。
Deployを押せば、とりあえずbotが動くはずです。@qrや@twtなどのコマンドが動くかを確認してください。


アプリを編集します。CallBack.javaを編集して不要なコマンド・画像とスタンプへの対応を除去し、必要なコマンドを追加します。

package ahuglajbclajep.linebot;

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Base64;
import java.util.EnumMap;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.imageio.ImageIO;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.HttpClients;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.net.URL;
import java.net.HttpURLConnection;
import java.nio.charset.StandardCharsets;

@WebServlet("/callback")
public class CallBack extends HttpServlet {
	private static final String APP_NAME = System.getenv("APP_NAME");
	private static final String SECRET_KEY = System.getenv("LINE_BOT_CHANNEL_SECRET");
	private static final String TOKEN = System.getenv("LINE_BOT_CHANNEL_TOKEN");
    
    private static String executeGet(String target_url) {
    	String ret = "";
        try {
            URL url = new URL(target_url);

            HttpURLConnection connection = null;

            try {
                connection = (HttpURLConnection) url.openConnection();
                connection.setRequestMethod("GET");

                if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
                    try (InputStreamReader isr = new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8); BufferedReader reader = new BufferedReader(isr)) {
                        ret = reader.readLine();
                    }
                }
            } finally {
                if (connection != null) {
                    connection.disconnect();
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return ret;
    }

	@Override
	public void doPost(HttpServletRequest req, HttpServletResponse res) {


		// 署名検証 //
		String sig = req.getHeader("X-Line-Signature");
		byte[] reqAll;
		try (InputStream in = req.getInputStream(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
			while (true) {
				int i = in.read();
				if (i == -1) {
					reqAll = out.toByteArray();
					break;
				}
				out.write(i);
			}

			Mac mac = Mac.getInstance("HmacSHA256");
			mac.init(new SecretKeySpec(SECRET_KEY.getBytes(), "HmacSHA256"));
			String csig = Base64.getEncoder().encodeToString(mac.doFinal(reqAll));

			if (!csig.equals(sig)) throw(new IOException());

		} catch (IOException | NullPointerException | NoSuchAlgorithmException | InvalidKeyException e) {
			res.setStatus(HttpServletResponse.SC_OK);
			return;
		}

		// 内容の解析 //
		ObjectMapper mapper = new ObjectMapper();
		JsonNode events;
		try {
			events = mapper.readTree(reqAll).path("events");
		} catch (IOException e) {
			res.setStatus(HttpServletResponse.SC_OK);
			return;
		}

		String replyMess;
		if ("message".equals(events.path(0).path("type").asText())) {  // メッセージを受けたとき
			replyMess = createReply(events.path(0).path("message"));

		} else if ("join".equals(events.path(0).path("type").asText())){  // トークの参加を受けたとき
			replyMess = "\"messages\":[{\"type\":\"text\", \"text\":\"こんにちは。コマンドを受け付けます。\"}]";

		} else {
			res.setStatus(HttpServletResponse.SC_OK);
			return;
		}

		// 返信する //
		HttpPost httpPost = new HttpPost("https://api.line.me/v2/bot/message/reply");
		httpPost.setHeader("Content-Type", "application/json");
		httpPost.setHeader("Authorization", "Bearer " + TOKEN);

		StringBuffer replyBody = new StringBuffer("{\"replyToken\":\"")
				.append(events.path(0).path("replyToken").asText())
				.append("\",")
				.append(replyMess)
				.append("}");

		httpPost.setEntity(new StringEntity(replyBody.toString(), StandardCharsets.UTF_8));

		try (CloseableHttpResponse resp = HttpClients.createDefault().execute(httpPost)) {
		} catch (IOException e) {}

		res.setStatus(HttpServletResponse.SC_OK);
	}


	private String createReply(JsonNode message){
		StringBuffer replyMessages = new StringBuffer("\"messages\":[");
		String type = message.path("type").asText();

		if ("text".equals(type)) {
			String[] args;
			int argc;
			args = message.path("text").asText().split(" ", 2);
			argc = args.length;
			boolean replied = false;

			if (args[0].indexOf("PC")>=0) {
				String checkPC = executeGet("http://[.tkドメイン:ポート]/check");
				if("up".equals(checkPC)){
					replyMessages.append("{\"type\":\"text\",\"text\":\"")
							.append("PCは起動しています。");
				}else{
					replyMessages.append("{\"type\":\"text\",\"text\":\"")
							.append("PCは起動していません。");
				}
				replied=true;
			}

			if (args[0].indexOf("起動")>=0) {
				replyMessages.append("{\"type\":\"text\",\"text\":\"")
						.append("起動します。");
				try {
					// HTTP GETでPC起動
					String wakePC = executeGet("http://[.tkドメイン:ポート]/wake");
				} catch (ArrayIndexOutOfBoundsException | DateTimeException e) {
					replyMessages.append("\"},").append("{\"type\":\"text\",\"text\":\"")
					.append("失敗しました。");
				}
				replied=true;
			}

			if(!replied){
				replyMessages.append("{\"type\":\"text\",\"text\":\"")
						.append("未定義コマンドです。");
			}

		} else if ("sticker".equals(type)) {  // スタンプが送られてきたとき
			replyMessages.append("{\"type\":\"text\",\"text\":\"")
					.append("スタンプには非対応です。");

		} else if ("image".equals(type)) {  // 画像が送られてきたとき
			replyMessages.append("{\"type\":\"text\",\"text\":\"")
					.append("画像には非対応です。");
		}
		replyMessages.append("\"}]");

		return replyMessages.toString();
	}

}

編集を保存、コミット・プッシュしたら、HerokuのDeploy > Manual deploy > Deploy Branchボタンを押します。
ログが流れ、Deploy to Herokuに緑のチェックが入ったら完了です。


「PC」を含むメッセージに反応して起動状態を確認・応答します。
「起動」を含むメッセージに反応して起動させ、応答します。


書いているうちに飽きて後半は雑な解説になりました。うまくいかなかったらコメント下さい。うまくいってもコメント下さい。
第1弾はwolだけで実現できる「PCの起動」でしたが、「PCのシャットダウン・スリープ」とかも出来たらいいなと思います。
最近は暑いので、外から窓を閉めてエアコンを入れておく、とか出来たらめっちゃ快適だなと野望を膨らませてもいます。スマートホームを作ろう。