LineBotで Ethereum の web3.jsを操作する

やりたいこと

Line Messaging APIを使って、Lineのトーク画面から、Ethereumの残高取得したり、送金したり。

 

イメージ図

Lineのトーク画面から直接ブロックチェーンへはアクセスできないため、Application Serverを経由しブロックチェーンからアカウントやトランザクションの情報を取得し、Line Messaging APIにリクエストを送るという流れ。
ただし、このモデルを用いる場合、残高やトランザクションの取得程度なら問題ありませんが、送金等、プライベートキーを扱う際に問題が生じます(後述)
とりあえず今回は残高取得のテストのみなので、このモデルで行います。

使用環境

1 Line Messaging APIの設定を行う

LINE DevelopersのStart using Messaging APIにてプロバイダ、チャンネルの登録を行います。

詳しい手続きは、前回エントリ Line Messaging API のFlex Messageを触ってみるを参照してください。

2 infura.ioのAPI KEYを取得する

infura.ioのGET STARTED FOR FREEボタンから、メールアドレスを登録するとAPI KEYを取得できます。

詳しい手続きは、前回エントリ ERC223トークンを時短でパブリックチェーンに公開する(忙しい人向け)を参照してください。

3 Glitchの設定を行う

GlitchはNode.jsが使えるPaasです。サインアップはGithubのアカウントで行うこともできます。
ログインしたら、New Projectから新しいプロジェクトを作成します。

今回、Etherbotというプロジェクトを作成しました。

4 必要なモジュールをインストールする

今回、追加するモジュールは

  • linebot — LineBotSDKの1つ
  • web3 — イーサリアム Javascript API
  • nedb — インストール不要のNoSQL

の3つです。Glitchプロジェクトフォルダ内のpackage.jsonをクリックし、Add Packagesから追加してください。

5 LineBotのクラスを定義する

LineBotアプリを構築する中で、Replyメッセージの作成やデータベースの読み書きを頻繁に行うため、一連の処理系をクラスにまとめて使いやすくしておきます。(正確にはES5の疑似クラスです) Replyメッセージの種類はたくさんありますが、よく使うtextとbuttonだけ定義しておきます。少々長いですが、まとめておくと後々楽になります。

const Linebot = function(app) {
   const linebot = require('linebot'); //linebot sdkの読み込み
   const Database = require('nedb'); //nedbの読み込み
   //各ユーザー端末のLineBotの状態を格納するためのデータベースファイルを指定
   const db={};
   db.botstatus = new Database({
      filename: '.database/botstatus', 
      autoload: true
   });

  //LineBotのチャンネルID等をコンストラクタに渡しthis.botに代入
   this.bot = linebot({
      channelId: process.env.CHANNEL_ID,
      channelSecret: process.env.CHANNEL_SECRET,
      channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN,
      verify: true
   });
   app.post('/', this.bot.parser());

   //Lineからのメッセージやポストバックのイベントを受取り、コールバック関数にイベントを渡す
   this.onMessageEvent = function (callback){
      this.bot.on('message',event => {
            this.setMessageTemplate(event);
            callback(event);
      });
      this.bot.on('postback',event => {
            this.setMessageTemplate(event);
            callback(event);
      });
  }

  //ReplyのたびにJSONをいちいち書くのは面倒なので、よく使うテキストやボタンだけ定義。
  //ボタンは4つまで設置できるので、可変長引数にしておく。
  this.setMessageTemplate = function(event){

      event.replyText = function(message){
           event.reply([{
              "type": "text",
              "text": message
           }]).then(data => {
                console.log('Success', data);
           }).catch(error => {
                console.log('Failed', error);
           });
      }

      event.replyButton = function(/*title,message,button,postback,...*/){
           var obj = {
              "type": "template",
              "altText": arguments[0],
              "template": {
                  "type": "buttons",
                  "thumbnailImageUrl": null,                     
                  "title": arguments[0],
                  "text": arguments[1],
                  "actions": [
                      {
                        "type": "postback",
                        "label": arguments[2],
                        "data": arguments[3],
                      }
                  ]
              }
           }
           for (var i = 0; i < 3; i++) { if(arguments.length > 2*i+4){
                obj.template.actions[i+1]={
                  "type":  "postback",
                  "label": arguments[2*i+4],
                  "data":  arguments[2*i+5] 
                };
             }
           }
          event.reply([obj]).then(data => {
                console.log('Success', data);
           }).catch(error => {
                console.log('Failed', error);
           });
      }

      //ユーザー端末のLineBotの状態をデータベースから読み込む
      this.readDatabase = function(lineID){
          return new Promise((resolve, reject) =>  {
              db.botstatus.findOne({ lineid: lineID }, (err, obj) =>{
                   if(err == null && obj!=null){
                       resolve(obj.status);
                   }
                   else if(err == null && obj==null){
                       resolve(null);
                   }
                   else{
                       reject(err);
                   }
              });
          });
      }

      //ユーザー端末のLineBotの状態をデータベースに書き込む
      this.writeDatabase = function(lineID,status){
          db.botstatus.findOne({ lineid: lineID }, (err, obj) => {
              if(obj==null){
                  db.botstatus.insert({'lineid':lineID,'status':status});  
              }
              else{
                  db.botstatus.update({ 'lineid': lineID }, { $set: { status: status } }, { multi: true });
              }
          });
      }
}

上のようなクラスを用意しておくと、

const express = require('express');
const app = express();
const bot = new Linebot(app);
bot.onMessageEvent(someFunction);

someFunction(event){
   event.replyButton(
      '選んでください','どれが好き?',
      'ビーフカレー','answer=beef',
      'ポークカレー','answer=pork',
      'チキンカレー','answer=chicken'
   );
   event.replyText('テストだよ');
}


結果

と、すこぶる快適になります。

6 Ethereumのweb3.jsを扱うクラスを定義する

残高取得のみなので、チェーンの選択、イーサリアムアドレスのvalidationがあれば十分かと思います。

const Ether = function(){
   const Web3 = require('web3');  //web3.jsの読み込み

   //mainnet,ropsten,rinkeby,kovanのどれかを指定してnodeとchain idを返す
   //infura.ioで取得したAPIアクセスキーを環境変数に入れて呼び出している。
   this.setChain = function(chain){
        switch(chain){
          case 'mainnet':
             return {'node':'https://mainnet.infura.io/'+ process.env.INFURA_KEY, 'id':1};
             break;
          case 'ropsten':              
             return {'node':'https://ropsten.infura.io/'+ process.env.INFURA_KEY, 'id':3};
             break;
          case 'rinkeby':              
             return {'node':'https://rinkeby.infura.io/'+ process.env.INFURA_KEY, 'id':4};
             break;
          case 'kovan':              
             return {'node':'https://kovan.infura.io/'+ process.env.INFURA_KEY, 'id':42};
             break;
          default:
             return {'node':'https://mainnet.infura.io/'+ process.env.INFURA_KEY, 'id':1};
             break;
       }
   }

   //正しいイーサリアムアドレスの形式になっているか確認する
   this.validateAddress = function(address){
      var valid = true;
      if(String(address) == ''){
          valid = false;
      }
      else if(
              String(address).length != 42
          || !String(address).match(/^[0-9a-zA-Z]/)
          || !(String(address).slice(0,2)=='0x')
      ){
          valid = false;
      }
      return valid;
   }

   //単位Etherで残高取得する
   this.getBalance = function(chain,address){
      const web3 = new Web3(new Web3.providers.HttpProvider(this.setChain(chain).node));
      return new Promise((resolve, reject) => {
          if(this.validateAddress(address)){
                resolve(web3.fromWei(web3.eth.getBalance(address), "ether").toNumber());
          }
          else{
                reject('Invalid address.');
          }
      });
   }
}

7 メイン処理を書く

上記5,6で作成したメソッド群を使用して、

1 ユーザーがLINEメッセージを送る
2 nedbにユーザーの状態とメッセージが保存される
3 Line Messaging APIがReplyを返す(質問)
4 ユーザーがLINEメッセージを送る
5 2で保存されたメッセージと4で送られたメッセージに基づいてイーサリアムから情報を取得する。
6 ユーザーの状態が初期化される
7 Line Messaging APIがReplyを返す(ユーザーがほしい情報)

という基本的な流れを実装します。

var Main = function(app){
    const bot = new Linebot(app);
    const ether = new Ether();

    //botインスタンスにgetActionメソッドを追加。
    //DBにユーザーの状態が保存されていない場合(初期状態)では、LINEで送られたクエリ形式のアクション
    // (action=showBalance 等)を解析して次のアクションを行う。

    bot.getAction = function(event,message){
        try{
            if(typeof(message['action']) == 'undefined' || message['action']==''){
                throw 'No Action Detected.';
            }
            switch(message['action']){
            case 'showBalance':
                bot.writeDatabase(event.source.userId,'action=showbalance&listen=chain');
                event.replyButton(
                    'Select Chain','Select Ethereum chain.',
                    'Mainnet','chain=mainnet',
                    'Ropsten','chain=ropsten',
                    'Rinkeby','chain=rinkeby',
                    'Kovan','chain=kovan'
                );
                break;
            default:
                throw 'No Action Detected.';
                break;
            }

          }catch(e){
               event.replyText(e);
          }
    }

    //メッセージかポストバックイベントを受け取ったら、まずどちらのイベントか判断する
    this.onMessageEvent = function(event){
        switch(event.type){
           case 'message':
              var message = functions.queryParse(event.message.text);      
              break;
           case 'postback':
              var message = functions.queryParse(event.postback.data);
              break;
           default:
              event.replyText('The event type is not supported.');
              break;
        }

        //ユーザーのLINE IDをキーにDBファイルからクエリ形式で状態を取得
        bot.readDatabase(event.source.userId).then(function(statusQuery) {

              //クエリ文字列をオブジェクトに変換する関数queryParseを定義しておく
              //action=shobalance等のクエリ形式を解析
              var status = queryParse(statusQuery); 

              if(status['action']=='showbalance' && status['listen']=='chain'){

                    if(typeof message['chain'] !== 'undefined'){
                        bot.writeDatabase(event.source.userId, 'action=showbalance&listen=address&chain=' + message['chain']); 
                        //chainの選択が終わったら、次はアドレスを取得するよう、ユーザー状態を変化させる。
                        event.replyText('Send '+ message['chain']+ ' Address.');
                    }
                    else{
                        bot.writeDatabase(event.source.userId, null); //ユーザー状態を初期化
                        event.replyText('Failed to select chain.');
                    }
              }
              else if(status['action']=='showbalance' && status['listen']=='address' && typeof status['chain'] !== 'undefined'){
                    ether.getBalance(status['chain'] , event.message.text).then(function(data){
                        event.replyText(data);
                    })
                    .catch(function(err){
                        event.replyText(err);
                    });
                    bot.writeDatabase(event.source.userId, null); //ユーザー状態を初期化
              }
              else{
                  bot.getAction(event,message);  //ユーザー状態を初期化
              }

        }).catch(function (err) {
            event.replyText(err);
        });
    }
    bot.onMessageEvent(this.onMessageEvent);
}
const express = require('express');
new Main(express());

上記を実行すると、

とLINEトーク画面からRopstenのEther残高を取得できました。

微妙な点

と、ここまで書いたものの、勉強用としてはまだしも、実用的には結構微妙な感じがします。なぜなら、

  • アドレスをコピペしなければいけない。特にスマホでの操作を想定しているので、かなり面倒。
  • LINEトーク画面上にjavascriptを埋め込めないので、送金等行う場合は、 アプリケーションサーバーにプライベートキーを送ってから署名等を行うという危険な行為を行うハメになる。

LINEトーク画面にこだわらなければ選択の幅は広がります。というわけで余力があれば次回はWebViewでフロントで動かしてみようと思います。

Leave a Reply

Your email address will not be published. Required fields are marked *