Calmery.me

みっかぼうずにならないようがんばる

ElectronでTwitterのOAuth認証をする

これは Electron Advent Calendar 2016 - Qiita 15 日目の記事です.

はじめに

Electron で Twitter と連携したアプリケーションを作りたいけれど,どうすればアプリケーションから Oauth 認証できるのかということで作りました.
github.com

$ git clone https://github.com/calmery/ElectronAdventCalendar2016.git
$ cd ElectronAdventCalendar2016
$ npm -g install electron-prebuilt
$ npm install
$ electron .
// common.js
// Twitter API Token
consumerKey: 'YOUR_CONSUMER_KEY',
consumerKeySecret: 'YOUR_CONSUMER_KEY_SECRET',

実装

Node.js で Web サーバを立てて,それを Electron から操作するといった感じ.Node.js と Electron の間は socket.io を使ってやり取りを行うようにした.Web サーバには http モジュールと express を使っている.

実際に http モジュールを使い Web サーバを立てるときにポート番号を指定することができるが,自動でポート番号を割り当てることもできる.こちらからポート番号を指定すると,ポート番号が競合してしまったり,他のブラウザなどから開けてしまうので自動で割り当てた方が個人的にはいい気がする.

// ポート番号を指定して実行
server.listen( 3000 );

// ポート番号を指定せずに実行
server.listen();

// ポート番号を取得
server.listen().address().port;

passport を使って認証する

肝心の認証だが passport を使うと簡単にできた.詳しくは Node.js + Express + passport-twitterで認証Webアプリをつくる - Qiita などを参考にすると良さそう.API キーの指定とルーティングをするだけでよかった.

const passport        = require( 'passport' ),
      twitterStrategy = require( 'passport-twitter' ),
      session         = require( 'express-session' );

app.use( passport.initialize() );
app.use( passport.session() );
app.use( session( { secret: 'lectern' } ) );

app.get( '/oauth', passport.authenticate( 'twitter' ) );
app.get( '/callback', passport.authenticate( 'twitter', { 
    successRedirect: '/',
    failureRedirect: '/fail' 
} ) );

passport.serializeUser( function( user, done ){ done( null, user ) } );
passport.deserializeUser( function( user, done ){ done( null, user ) } );

passport.use( new twitterStrategy.Strategy( {
    consumerKey   : 'CONSUMER_KEY',
    consumerSecret: 'CONSUMER_KEY_SECRET',
    callbackURL   : 'http://127.0.0.1:' + port + '/callback'
}, function( token, tokenSecret, profile, done ){
    console.log( token, tokenSecret );
    ...
} ) );

js-yamlトークンを保存する

取得したトークンは js-yaml を使用して保存した.

// common.js
const jsYaml = require( 'js-yaml' );

module.exports = {
    
    getAbsolutePath: function(){
        return path.resolve( path.join.apply( this, [].slice.call( arguments ) ) );
    },
    
    writeFileSync: function( filePath, content ){
        try {
            fs.writeFileSync( this.getAbsolutePath( filePath ), content );
            return true;
        } catch( error ){
            return false;
        }
    },
    
    jsYaml: jsYaml
    
};

// express.js
const common = require( './common' );

common.writeFileSync( 'user.yaml', common.jsYaml.safeDump( {
    access_token: token,
    access_token_secret: tokenSecret
} ) );

こんな YAML ファイルが作られる.

access_token: YOUR_ACCESS_TOKEN
access_token_secret: YOUR_ACCESS_TOKEN_SECRET

ちなみにアプリケーションを立ち上げたとき,このアクセストークンを保存したファイルが無ければ認証ページにリダイレクトするようにしている.

app.get( '/', function( request, response ){
    if( common.exists( 'user.yaml' ) ) 
        response.sendFile( common.getAbsolutePath( 'public/index.html' ) );
    else 
        response.redirect( '/oauth' );
} )

TwitterAPI を使う

Twitter API の呼び出しは関数にまとめた.

// twitter.js
module.exports.getClient = function(){

    const config = common.yamlLoader( 'user.yaml' );

    const client = new twitter( {
        consumer_key       : common.consumerKey,
        consumer_secret    : common.consumerKeySecret,
        access_token_key   : config.access_token,
        access_token_secret: config.access_token_secret
    } );

    return {

        getUserTweet: function( condition ){
            return new Promise( function( resolve, reject ){
                client.get( 'statuses/user_timeline', makeOption( condition ), function( error, tweet, response ){
                    if( error === null ) 
                        resolve( tweet );
                    else 
                        reject( error );
                } );
            } );
        },
        ...
// index.js
io.sockets.on( 'connection', function( socket ){
    
    client = twitter.getClient()
    
    const emit = function( key, content ){
        io.sockets.to( socket.id ).emit( key, content );
    };
    
    const emitError = function( content ){
        emit( 'error', content );
    };
    
    socket.on( 'getUserTweet', function( user_id ){
        client.getUserTweet( user_id ).then( function( tweet ){
            emit( 'userTweet', tweet );
        }, emitError );
    } );
    
} );

ツイートを表示する

Electron 側での表示はこんな感じ.

<script src="/socket.io/socket.io.js"></script>
<script src="resources/preload.js"></script>
<script src="resources/index.js"></script>
// preload.js
var socket = io()

socket.on( 'error', function( error ){
    console.log( error )
} )

// index.js
socket.emit( 'getUserTweet', 'calmeryme' )

socket.on( 'userTweet', function( tweet ){
    
    for( var i=0; i<tweet.length; i++ )
        document.body.innerHTML += tweet[i].text + '<br><br>'
    
} )

指定したユーザのツイートを表示できた.
f:id:calmery:20161214211134p:plain

まとめ

Electron を使って Oauth 認証し,ユーザのツイートを取得することができた.Web の知識だけで簡単にアプリケーションを作ることができるのは本当にありがたい.Electron 万歳.あとは electron-packager を使って配布できる形式にすればいい.パッケージ化したアプリケーションの容量が大きくなってしまうことが難点だがよりアプリケーションっぽくなる.30分で出来る、JavaScript (Electron) でデスクトップアプリを作って配布するまで - Qiita が参考になった.もうほんとライブラリさまさまなので感謝しかない><

JPHACKS AwardDay(やっぱり東京は凄かった)

JPHACKS AwardDay に参加してきました.賞は何も取れなかった!残念すぎる…
終結果は JPHACKS 2016 最終審査結果を公開しました | JPHACKS | 全国6都市で開催する、日本最大級の学生向けハックイベント で見ることができる.他の作品が凄すぎる…

プレゼンの様子は JPHACKS | FRESH!(フレッシュ) - 生放送がログイン不要・高画質で見放題 で見ることができる.2時間47分辺りの,キュートなくまモンの姿を見ていただきたい(恥ずかしい)

ちょっと未来に生きすぎた.いい感じに写真も撮っていただいた.好きと言ってもらえたのがせめてもの救い.IMG_2450
落ち着いて考えるとお花とお話なんて頭おかしい.でも逆にそれが受けたのかなと思ったり思わなかったり.

ちなみに AwardDay の前日,ポケモン買いました.発売日に買えたので良かった.というか,オシャマリ可愛すぎませんか?

Givery の村上さんの勧めで Mashup Award にも申し込んだ.学生部門のオンライン選考で 部門賞決勝審査 に出ることができた.ただ当日は誰も行くことができなかった.残念すぎる…講義がなければ行きたかった.

ほとんど見ているだけになってしまったけど楽しかった.JPHACKS には来年も絶対出るし AwardDay 出たいし賞も取りたい.頑張る.
JPHACKS2016で作ったものまとめ - Calmery.me
IMG_3238
JP HACKS | Flickr

JPHACKS2016で作ったものまとめ

追記
追加オンライン審査で選ばれた決勝進出チーム9組を発表 | JPHACKS | 全国6都市で開催する、日本最大級の学生向けハックイベント
追加オンライン審査で Award Day に進むことができました!

昨年に引き続き今年も JPHACKS に参加してきた.今年は熊本ではなく福岡で.Rubyコンテンツ産業振興センターでの開催だからか Ruby を使用したチームから選ばれる賞というものもあった.
f:id:calmery:20161103212816j:plain
今年は「FlowerTalk」という,実際に自分が撮影したお花さんとお話しできるアプリを作った.メルヘンチック.
f:id:calmery:20161103213206j:plain
詳細は Flower Talk | Devpost にまとめてある.開発は GitHub - jphacks/KS_1604 で行うはずだったが,権限をもらっていなかったので GitHub - calmery/ks_1604: JPHACKS 2016 を作ってそちらで行った.

結果はダメだったが,せっかくなのでまとめておこうと思う.

Devpost にある動画を見てもらえればわかるが,アプリでは背景として映像を流し続けている.このアプリ自体,ウェブベースなので getUserMedia を使用し Web カメラから映像を取得,そのまま画面に描写した.

var video = document.getElementById( 'video' )

navigator.webkitGetUserMedia( {
    video: true, 
    audio: false
}, function( localMediaStream ){
    video.src = window.URL.createObjectURL( localMediaStream )
}, function(){
    console.log( 'Error' )
} )

花が写っているかの判定を行う際に画像が必要になる.画像は取得した映像の現在のフレームを Canvas に描写し,送信するようにしている.

var canvas = document.getElementById( 'capture' ),
    ctx    = canvas.getContext( '2d' )

canvas.width  = 640
canvas.height = 480

function capture(){
    ctx.drawImage( video, 0, 0, 640, 480 )
    socket.emit( 'capture', canvas.toDataURL( 'png' ) )
}

NodeJS 側で画像データを受け取り Microsoft Computer Vision API を使用し写っているものを判別する.花が写っていれば次の処理へ進む.そういえば花が写っていない場合の処理を書いていない.本当は Vision API - Image Content Analysis  |  Google Cloud Platform を使いたかったがクレジットカードが必須だった.残念.

function checkType( filePath ){
    child_process.exec( 'python ./vision/msVision.py ' + filePath, function( error, stdOut, stdError ){
        if( !error && !stdError ){
            const data = JSON.parse( stdOut )
            const tags = data.description.tags.filter( ( e ) => e.toLowerCase() ).join( '' )
            if( tags.indexOf( 'flower' ) !== -1 || tags.indexOf( 'plant' ) !== -1 )
                setNewFlower( data )
        } else
            console.log( error )
    } )
}

socket.on( 'capture', ( raw ) => {
    const path = utility.fixPath( __dirname, 'tmp', 'captured.png' )
    fs.writeFile( path, utility.decodeBase64Image( raw ).data, function( error ){ 
        if( !error ) checkType( path )
    } )
} )

画像からの判別は時間がなかったので Python で書かれたものをほとんどそのまま使った.結果を出力して NodeJS 側で受け取り,使用する.

import httplib
import urllib

import sys

file_name = sys.argv[1]

headers = {
    'Content-Type': 'application/octet-stream',
    'Ocp-Apim-Subscription-Key': 'YOUR_KEY',
}
params = urllib.urlencode( {
    'visualFeatures': 'Description',
} )

connect = httplib.HTTPSConnection( 'api.projectoxford.ai' )

img = open( file_name, 'rb' ).read()

connect.request( 'POST', '/vision/v1.0/analyze?%s' % params, img, headers )
response = connect.getresponse()
caption_data = response.read()
connect.close()

print( caption_data )

判定の結果として以下のようなレスポンスが得られる.

{
    "description": {
        "tags": ["plant", "flower", "daisy", "vase"],
        "captions": [{
            "text": "a vase with flowers in it",
            "confidence": 0.363922346433229
        }]
    },
    "requestId": "8e3b3a10-1763-4e68-ab2c-308d9a68595a",
    "metadata": {
        "width": 640,
        "height": 480,
        "format": "Png"
    }
}

この結果のタグ部分を見て植物,花かどうかを判定する.発表の際に,花の種類は判別できるのかと聞かれた.その場はできないと答えたが,この結果の場合,タグの中に花の名前が含まれている.だが,結果によっては含まれていないこともあったので API の利用は植物,花かどうかの判定のみに絞った.

その後の会話部分はユーザーローカルの人工知能ボット API を使用した.簡単な使い方は ユーザーローカルの人工知能ボットAPIを使ってTwitterの自動返答ボットを作った - Calmery.me にまとめてある.今回はこれに加えキャラクター会話変換 API を使用している.使い方はいつの間にかここにまとめられていた.実を言うと本当は docomo の雑談対話 API を使ってみたかった.

返答は一度,ユーザーローカルの人工知能ボット API から返答のメッセージを受け取り,そのメッセージをキャラクター会話変換 API に投げる.「ワン」とか「ニャン」とかついて返ってくる.花が「ワン」とか「ニャン」とか喋る.この語尾だが,花の性格で決まるようになっている.性格が決まっていなければお嬢様っぽく「ですわ」と喋ってくれる.性格は花の色,その色の濃さで決めようと思っていた.結局間に合わず,ランダムで決めている.色相の角度で決めている.

request.get( config.message.base + config.message.endPoint.chat + '?message=' + encodeURI( message ) + '&key=' + config.message.key, ( error, response, body ) => {
    if( !error && response.statusCode == 200 ){
        var responseMessage = JSON.parse( body ).result

        if( me.personality !== undefined ){
            request( config.message.base + config.message.endPoint.character + '?message=' + encodeURI( responseMessage ) + '&key=' + config.message.key + '&character_type=' + me.personality, ( error, response, body ) => {
                if( !error && response.statusCode == 200 )
                    io.sockets.to( socket.id ).emit( 'message', JSON.parse( body ).result )
            })
        } else {
            responseMessage = responseMessage.replace( /[?|?|!|!]/g, '' )
            responseMessage += 'ですわ'
            io.sockets.to( socket.id ).emit( 'message', responseMessage )
        }
    }
} )

動くからいいがとても遅い.さらにメッセージによっては「ワン」や「ニャン」にうまく変換されていない.

会話を行う際に「名前ちゃんLINE教えて」と打つとこの花に「名前」という名前をつけることができる.「ふらわぁちゃんLINE教えて」と言えば名前は「ふらわぁ」となる.これで LINE を使ってこの花と会話できる.と言っても今のところ自分の名前を呼んでくれるだけなので意味がない.

var isCheckName = false
var key

if( isCheckName || message.toLowerCase().indexOf( 'line' ) !== -1 && message.indexOf( '教' ) !== -1 ){
    if( !isCheckName ){
        key = message.replace( /[LINE|教えて|ちゃん|!|!]/g, '' )
        io.sockets.to( socket.id ).emit( 'message', 'あなたの名前は?' )
        isCheckName = true
    } else {
        // key = require('crypto').createHash('md5').update(Date(), 'buffer').digest('hex')
        var value = message
        request.get( 'http://calmery.me/postNameData.php?key=' + encodeURI( key ) + '&value=' + encodeURI( message ), function( error, response, body ){
            if( !error )
                io.sockets.to( socket.id ).emit( 'message', message + 'さん!<br>LINEで「召喚!' + key + '」とメッセージを送ってね' )
        } )
        isCheckName = false
    }
}

花の名前はユーザの名前と関連付けられて別に用意したサーバ上に保存される.LINE の Bot で,その名前のリストを受けとりユーザの名前を呼ぶようにしている.

LINE の Business アカウントを作成し Messaging API を利用する.
f:id:calmery:20161103203526p:plain
さくらインターネット上で動かした.共有 SSL を使用し Flask で動かしている.さくらインターネットでは Python のインストールから必要となるがそこは調べれば出てくる.個人的に Flask をインストールして動かすところには時間がかかったのでメモしておく.

$ pip install Flask
...
Successfully installed Flask-0.11.1 Jinja2-2.8 MarkupSafe-0.23 Werkzeug-0.11.11 click-6.6 itsdangerous-0.24

$ cd www
$ mkdir ~/www/flask-cgi-test
$ mkdir ~/www/flask-cgi-test/flowertalk
$ cd ~/www/flask-cgi-test/flowertalk
$ vim .htaccess

1 RewriteEngine On
2 RewriteCond %{REQUEST_FILENAME} !-f
3 RewriteRule ^(.*)$ /flask-cgi-test/flowertalk/index.cgi/$1 [QSA,L]

$ vim appFlask.py

1 # coding: utf-8
2 
3 from flask import Flask
4 app = Flask(__name__)
5 
6 @app.route('/')
7 def index():
8     return "Hello!"
9         
10 if __name__ == '__main__':
11     app.run()

$ vim index.cgi

1 # coding: utf-8
2 
3 from flask import Flask
4 app = Flask(__name__)
5 
6 @app.route('/')
7 def index():
8     return "Hello!"
9 
10 if __name__ == '__main__':
11     app.run()

パーミッションは 755 にする必要がある.

suexec failure: could not open log file, referer: http://sojo-patchworks.sakura.ne.jp/flask-cgi-test/
fopen: Permission denied, referer: http://sojo-patchworks.sakura.ne.jp/flask-cgi-test/
Premature end of script headers: index.cgi, referer: http://sojo-patchworks.sakura.ne.jp/flask-cgi-test/
suexec failure: could not open log file, referer: http://sojo-patchworks.sakura.ne.jp/flask-cgi-test/
fopen: Permission denied, referer: http://sojo-patchworks.sakura.ne.jp/flask-cgi-test/
Premature end of script headers: index.cgi, referer: http://sojo-patchworks.sakura.ne.jp/flask-cgi-test/

動作確認後,以下のように書き換えた.

# -*- codinf: utf-8 -*-
#! /home/sojo-patchworks/local/python/bin/python3
from flask import Flask, request, abort
import json
import requests
import urllib.parse

from linebot import ( LineBotApi, WebhookHandler )
from linebot.exceptions import ( InvalidSignatureError )
from linebot.models import ( MessageEvent, TextMessage, TextSendMessage, )

app = Flask(__name__)

line_bot_api = LineBotApi('CHANNEL_ACCESS_TOKEN')
handler = WebhookHandler('CHANNEL_SECRET')

@app.route("/callback", methods=['POST'])
def callback():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = json.loads(request.get_data(as_text=True))
    
    if not ("召喚!" in body['events'][0]['message']['text']) :
        r = requests.get( 'https://chatbot-api.userlocal.jp/api/chat?message=' + urllib.parse.quote(body['events'][0]['message']['text']) + '&key=USERLOCAL_API_KEY' )
        line_bot_api.reply_message(body['events'][0]['replyToken'],TextSendMessage(text=json.loads(r.text)['result']))
    else :
        line_bot_api.reply_message(body['events'][0]['replyToken'],TextSendMessage(text=json.loads( requests.get( 'http://calmery.me/getNameData.php' ).text )[body['events'][0]['message']['text'].replace('召喚!', '')] + 'さん!おかえりなさい!'))

    return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    line_bot_api.reply_message(
        event.reply_token,TextSendMessage(text='Hello World!'))

無理やりとか言っちゃダメ.ここも単純にユーザーローカルの API にメッセージを投げて返しているだけ.「召喚!」が含まれている場合のみユーザ名と花の名前を取得しユーザの名前を呼ぶ.重複の判定がないので他のユーザに名前を上書きされるとその名前で呼んでしまう.それはそれで浮気っぽくていいかもしれない.ちなみに「召喚!」というのは眠さのあまり適当につけたもので特に意味はない.
f:id:calmery:20161103212920p:plain
遠出したときも花を見たいよねということで SkyWay の WebRTC の API も使用した.SkyWay に登録後,利用可能ドメインの設定を行う必要がある.ローカルでもいけた.これは全くと言っていいほど時間がかからず,サンプルそのままで動いた.
f:id:calmery:20161103205808p:plain

navigator.getUserMedia( {
    audio: true, 
    video: true
}, function( stream ){
    localStream = stream
}, function(){ 
    console.log( 'Error' ) 
} )

var call = peer.call( peer_id, localStream )
call.on( 'stream', function( stream ){
    var url = URL.createObjectURL( stream )
    document.getElementById( 'peerVideo' ).src = url
} )

peer.on( 'call', function( call ){
    connectedCall = call
    call.answer( localStream )
    call.on( 'stream', function( stream )
        var url = URL.createObjectURL( stream )
        document.getElementById( 'peerVideo' ).src = url
    } )
} )

結局,色々な API を使っただけなのではという気はしている.スマートフォンでも同じような動作をするアプリを作ってもらっていたがこちらは間に合わず断念.撮影の際に植木鉢を画面下からスライドして投げるようにしてある.名前は「FlowerG○」にしたい.

センサーで植物,花の管理を LINE 上で行いたかった.「元気?」と聞くと「喉が渇いた」と返してくれるような.そのために Raspberry Pi も持って行ってはいたが画面もなかったしセンサーもないのでどうしようもなかった.ただ会話だけ,遊ぶ分には面白いのかなと思う.

ふらわぁちゃんは弄ぶのがうまい.
f:id:calmery:20161103212952p:plain
f:id:calmery:20161119105623p:plain