LINE APIが公開されたのでGoogle Cloud Vision使って画像解析しちゃうBOTをAWS Lambdaで作ってみた話

  • 251
    Like
  • 1
    Comment

LINE BOT API Trial

4/7(木)にLINE BOT API Trialが提供されて数日でQiitaに記事があがっていてみんな早いなーと。

「自分も早くなんか作らないと」という焦りを感じてこの土日でLINE BOT作りました。

アカウント登録

登録は簡単。
https://developers.line.me
から登録していきます。

BOT名とかプロフィール画像とかは後からでも変えられるので適当でOK。
Callback関数も適当に。

どんなBOTつくるか

GoogleCloudVisionの記事書いて、その時使ってみて感動したんですよね。
けど、もっとこの感動を皆に伝えたい!と思ってたんで
LINEという使い慣れたインターフェイスでGoogleCloudVisionを使えるようにしました。

あと、おまけみたいな感じですが「時間」って投げると現在時刻教えてくれたり。

LINE BOTサーバからcallback受け取るためにサーバが必要だったりしかもhttps使えるようにしなきゃいけなかったりオレオレ認証だめとかって噂があったので
ここはやはりサーバレスということでAWSのLambdaをつかうことにします。

BOTを作る

処理の流れを考える

メッセージをもらって画像解析して結果を返す部分のざっくりとした処理の流れを図にしてみました。
line.png

  1. ユーザがスマホのLINEアプリからボット宛にメッセージを送信する
  2. LINE Serverが予め指定したCallbackを叩く(Bodyにはメッセージの情報を含むjson)
  3. 画像IDに紐づく画像データをLine Serverにリクエストする
  4. LINE Serverから画像の生データが返ってくる
  5. 画像データをBase64エンコードしてGoogleCloudVisionにPOSTする
  6. GoogleCloudVisionから解析結果がjsonで返ってくる
  7. 解析結果から返信メッセージを整形する
  8. Line Serverにメッセージ投稿リクエストを投げる
  9. ユーザにメッセージが届く

と、こんな感じです。

Lambda Functionのコーディング

認証のための鍵情報は別ファイルで持っときます。

これはLINE API用(line_key.json)

{"channelID": "", "channelSecret": "", "mid": ""}

これらの情報は開発者用ダッシュボードから取得できます。

これがGoogle Cloud Vision用(google_key.json)

{"key": ""}

これもGoogleのコンソールから取ってきましょう。

Lambda関数はこんな感じで作りました。

var request = require('request');
var async = require('async');
var res;
var line_key = require('./line_key.json');
var google_key = require('./google_key.json');

exports.handler = function(event, context) {
    console.log('Received event:', JSON.stringify(event, null, 2));
    res = event.result[0];

    async.waterfall([
        function recognize(callback) {
            if (res.content.contentType === 2) {
                callback(null, 'image');
            } else if (res.content.text.match(/^時間/)) {
                callback(null, 'time');
            } else {
                callback(null, 'other');
            }
        },
        function run(data, callback) {
            if (data === 'image') {
                console.log('run image');
                async.waterfall([
                    function getImage(callback2) {
                        console.log('get image');
                        var id = res.content.id;

                        var opts = {
                            url: 'https://trialbot-api.line.me/v1/bot/message/' + id + '/content',
                            headers: {
                                "Content-type": "application/json; charset=UTF-8",
                                "X-Line-ChannelID": line_key.channelID,
                                "X-Line-ChannelSecret": line_key.channelSecret,
                                "X-Line-Trusted-User-With-ACL": line_key.mid
                            },
                            encoding: null
                        }
                        request(opts, function(error, response, body) {
                            if (!error && response.statusCode == 200) {
                                var img = body.toString('base64')
                                callback2(null, img);
                            } else {
                                callback2(error);
                            }
                        });
                    },
                    function sendCloudAPI(img, callback2) {
                       console.log('send cloud api');
                        var data = {
                            "requests":[
                                {
                                    "image":{"content": img},
                                    "features":[
                                        {"type": "FACE_DETECTION", "maxResults": 5},
                                        {"type": "LABEL_DETECTION", "maxResults": 5},
                                        {"type": "TEXT_DETECTION", "maxResults": 5},
                                        {"type": "LANDMARK_DETECTION", "maxResults": 5},
                                        {"type": "LOGO_DETECTION", "maxResults": 5},
                                        {"type": "SAFE_SEARCH_DETECTION", "maxResults": 5}
                                    ]
                                }
                            ]
                        };
                        var opts = {
                            url: 'https://vision.googleapis.com/v1/images:annotate?key=' + google_key.key,
                            headers: {'Content-Type': 'application/json'},
                            body: JSON.stringify(data)
                        }
                        var text = '';
                        request.post(opts, function (error, response, body) {
                            console.log(body);
                            body = JSON.parse(body);
                            var labelAnnotations = body.responses[0].labelAnnotations;
                            var faceAnnotations = body.responses[0].faceAnnotations;
                            var textAnnotations = body.responses[0].textAnnotations;
                            var landmarkAnnotations = body.responses[0].landmarkAnnotations;
                            var logoAnnotations = body.responses[0].logoAnnotations;
                            var safeSearchAnnotation = body.responses[0].safeSearchAnnotation;
                            if (labelAnnotations !== undefined) {
                                for (var i = 0; i < labelAnnotations.length; i++) {
                                    text += '"' + labelAnnotations[i].description + '"' + "とか\n";
                                }
                                text += "まぁその辺りじゃないかな\n\n";
                            }
                            if (faceAnnotations !== undefined) {
                                text += "人間が" + faceAnnotations.length + "人いるみたいだね\n\n";
                            }
                            if (textAnnotations !== undefined) {
                                text += "「" + textAnnotations[0].description.replace(/\n/g, ' ') + "」とかって書いてあるなぁ\n\n";
                            }
                            if (landmarkAnnotations !== undefined) {
                                text += "あ!これ場所は" + landmarkAnnotations[0].description + "だよね!\n\n";
                            }
                            if (logoAnnotations !== undefined) {
                                text += "ってかこれ「" + logoAnnotations[0].description + "」じゃね?www\n\n";
                            }
                            if (safeSearchAnnotation !== undefined && (safeSearchAnnotation.adult === 'LIKELY' || safeSearchAnnotation.adult === 'VERY_LIKELY')) {
                                text += "あ、いや、、、てかこれ…ちょっとエッチ///\n\n";
                            }
                            text = text.replace(/\n+$/g,'');
                            callback2(null, text);
                        });
                    }
                ], function (err, result) {
                    callback(null, result);
                });
            } else if (data === 'time') {
                console.log('run time');
                var date = new Date();
                var year = date.getFullYear();
                var month = date.getMonth()+1;
                var week = date.getDay();
                var day = date.getDate();
                var hour = date.getHours();
                var minute = date.getMinutes();
                var second = date.getSeconds();
                var text = year+"年"+month+"月"+day+"日"+hour+"時"+minute+"分"+second+"秒";
                callback(null, text);
            } else {
                console.log('run else');
                var text = 'いや、ちょっとなに言ってるか分かんないっすw';
                callback(null, text);
            }
        },
        function postToLine(text, callback) {
            console.log('run post : ' + text)
            var data = {
                to: [res.content.from.toString()],
                toChannel: 1383378250,
                eventType: "138311608800106203",
                content: {
                    "contentType":1,
                    "toType":1,
                    "text":text
                }
            };
            var opts = {
                url: 'https://trialbot-api.line.me/v1/events',
                headers: {
                    "Content-type": "application/json; charset=UTF-8",
                    "X-Line-ChannelID": line_key.channelID,
                    "X-Line-ChannelSecret": line_key.channelSecret,
                    "X-Line-Trusted-User-With-ACL": line_key.mid
                },
                body: JSON.stringify(data)
            }
            request.post(opts, function (error, response, body) {
                if (!error && response.statusCode == 200) {
                    callback(null);
                } else {
                    console.log(JSON.stringify(body));
                    callback(error);
                }
            });
        }
    ], function (err, result) {
    });
};

エラーハンドリングとか超適当なのはごめんなさいです。。。

コードはこちらにおいておきますね。
https://github.com/donkeykey/CloudVision_LineBot

できたらAPI GATEWAY設定してPOSTでLambda関数が動くようにします。
APIのURLがわかったらそのURLをLINEのダッシュボードからCallback URLに設定します。

この状態で投稿してもWhitelist IPを登録しないとBOTからの投稿ができません。
CloudWatchのエラーログにIPが出力されているのでサブネットを広めに取って登録しました。

感想

  • だいたいの人が連絡手段として使っているLINEという最強のプラットフォーム上でいろんなアプリケーションを提供できる!
    • 幅広いユーザにリーチ出来そう
    • (BOTがグループ会話の一員になれれば)拡散性が高そう
  • 使い慣れたLINEアプリ/インターフェイス上で動く
    • 開発者はhtmlもcss書く必要ない!
    • xcode起動してswiftでゴリゴリコード書く必要ない!
  • いろいろ作れそう!
    • 近所のおすすめレストランをレコメンドしたり
    • BOTとチャットするだけでネットショッピングできたり
    • 家電操作したり

まあ要はSiriのチャット版っというイメージで使えそうですね。
なにか必要な情報を得たりアクションするためのコマンドライン的なイメージでも良さそう。
Siriは電車の中とか声を出せない状況では使えないのが結構微妙ポイントだったりするのでチャットボットはその点クリアされますし、汎用性高そうです。

使ってみて!

IMG_8084.png
画像を投げるだけでいろいろ解析してくれます!

ここから友達登録できるはずなんで。
でも50人限定なのでお早めに。
→すみません、50人に達したようですm(__)m
ztf0043d.png

問題点

Lambda使うとIPがころころ変わるのでLINEのWhitelistIP指定で一番広い/24サブネットではカバーしきれませんでした。
なんとかできないか模索します。
→とりあえずレンタルサーバ上にプロキシを立てて、ここを経由させることで固定IPでAPIを叩くようにしました…が、サーバレスなシステムではなくなってしまうのでAWSのVPCなるものを使ってみたいと思ってます。