Clova,LINEBot,LINEPayをつないで、自分のカフェで利用可能なプロダクトをGCPに実装

# Clova,LINEBot,LINEPayをつなぐ

今回は、Clova,LINEBot,LINEPayをつないで、オリジナルのプロダクトを作成しました。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">クローバーで商品購入のスキルを発動して、LINE botに商品の詳細内容を送るとこに成功！<a href="https://twitter.com/hashtag/protoout?src=hash&amp;ref_src=twsrc%5Etfw">#protoout</a> <a href="https://twitter.com/hashtag/%E3%83%87%E3%82%B8%E3%82%BF%E3%83%AB%E3%83%98%E3%83%AB%E3%82%B9%E5%AD%A6%E4%BC%9A?src=hash&amp;ref_src=twsrc%5Etfw">#デジタルヘルス学会</a> <a href="https://twitter.com/hashtag/%E8%A9%95%E8%AB%96%E5%AE%B6%E3%81%A7%E3%81%AF%E3%81%AA%E3%81%8F%E5%89%B5%E9%80%A0%E8%80%85%E3%81%AB%E3%81%AA%E3%82%8D%E3%81%86?src=hash&amp;ref_src=twsrc%5Etfw">#評論家ではなく創造者になろう</a> <a href="https://t.co/gIDjh9oUbl">pic.twitter.com/gIDjh9oUbl</a></p>&mdash; 北城雅照|プログラミングにはまったサーフィン好きの整形外科医 (@teru3_kitashiro) <a href="https://twitter.com/teru3_kitashiro/status/1344079095966023680?ref_src=twsrc%5Etfw">December 30, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

GCPにデプロイしたので、最終的に実際のお店でも問題なく利用できるものに限りなく近付いたのではないかと考えています。

完成したプロダクトのコードは[こちらgithub](https://github.com/MasateruKitashiro/clova-LINEBot-LINEPay/tree/master)に置いてあります。

また、今回はこちらの[LINEAPI実践ガイド](https://www.amazon.co.jp/LINE-API%E5%AE%9F%E8%B7%B5%E3%82%AC%E3%82%A4%E3%83%89-API-Expert-%E8%AA%8D%E5%AE%9A%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC/dp/4839973768/ref=sr_1_1?dchild=1&keywords=line+api&qid=1609290696&sr=8-1)を参考に作成いたしました。初学者には結構難解な書籍でしたが、この山を乗り越えられて、少し成長した気がしました。

## Clovaの設定
[書籍](https://www.amazon.co.jp/LINE-API%E5%AE%9F%E8%B7%B5%E3%82%AC%E3%82%A4%E3%83%89-API-Expert-%E8%AA%8D%E5%AE%9A%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC/dp/4839973768/ref=sr_1_1?dchild=1&keywords=line+api&qid=1609290696&sr=8-1)を参考に、Clovaのスキルを設定していきました。
コードを以下に記します。

```javascript:ClovaSkill.js
'use strict';
const clova = require('@line/clova-cek-sdk-nodejs');
const line = require('@line/bot-sdk');
const jsonData = require('../data.json');

// LINE BOTの設定
const config = {
    channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN || 'test',
    channelSecret: process.env.CHANNEL_SECRET || 'test'
};
const base_url = process.env.BASE_URL;

const client = new line.Client(config);
const repromptMsg = 'パーカーですか？tシャツですか？'

module.exports = clova.Client
  .configureSkill()

  //起動時
  .onLaunchRequest(async responseHelper => {
    console.log('onLaunchRequest');
    
    const speech = [
        clova.SpeechBuilder.createSpeechUrl('https://clova-soundlib.line-scdn.net/clova_behavior_door_knock.mp3'),
        clova.SpeechBuilder.createSpeechUrl('https://clova-soundlib.line-scdn.net/clova_behavior_door_open.mp3'),
        clova.SpeechBuilder.createSpeechText('こんにちは！カフェマエマエへようこそ。ご希望の商品は' + repromptMsg)
      ];
    
    responseHelper.setSpeechList(speech);
    responseHelper.setReprompt(getRepromptMsg(clova.SpeechBuilder.createSpeechText(repromptMsg)));
  
  })

  //ユーザーからの発話が来たら反応する箇所
  .onIntentRequest(async responseHelper => {
    const intent = responseHelper.getIntentName();
    console.log('Intent:' + intent);
    switch (intent) {
      // ヘルプ
      case 'Clova.GuideIntent':
        const helpSpeech = [
          clova.SpeechBuilder.createSpeechText('スキルの説明をします。カフェマエマエで販売している商品を、こちらからご購入可能です。'),
          clova.SpeechBuilder.createSpeechText(repromptMsg)];
          responseHelper.setSpeechList(helpSpeech);
          responseHelper.setReprompt(getRepromptMsg(clova.SpeechBuilder.createSpeechText(repromptMsg)));
        break;
      
      case 'GoodsSearchIntent':
        const slots = responseHelper.getSlots();
        const goods = slots.goods;
        
        const goodsSpeech = [];
        console.log(slots.goods);
        
        // ユーザID取得
        const { userId } = responseHelper.getUser();

        // パーカーかtシャツの選択
        let goodsEn;
        if (goods === 'パーカー') {
          goodsEn = 'parker';
        } else if (goods === 'tシャツ') {
          goodsEn = 'tshirt';
        } else {
          goodsSpeech.push(clova.SpeechBuilder.createSpeechText('聞き取れませんでした。もう一度お願いします。' + repromptMsg));
          responseHelper.setSpeechList(goodsSpeech);
          return;
        }

        // オススメの商品をBOTへ送信
        await sendLineBot(userId, jsonData[goodsEn], goodsEn)
          .then(() => {
            if (goods === 'パーカー') {
              goodsSpeech.push(clova.SpeechBuilder.createSpeechText('パーカーのおすすめ商品をボットに送信しました。ご確認くださいませ。'));
            } else {
              goodsSpeech.push(clova.SpeechBuilder.createSpeechText('tシャツのおすすめ商品をボットに送信しました。ご確認くださいませ。'));
            }
          })
          .catch((err) => {
            console.log(err);
            goodsSpeech.push(clova.SpeechBuilder.createSpeechText('botを連携させてください。'));
          });
        
        goodsSpeech.push(clova.SpeechBuilder.createSpeechText('また、ご利用くださいませ。'));
        goodsSpeech.push(clova.SpeechBuilder.createSpeechUrl('https://clova-soundlib.line-scdn.net/clova_behavior_door_close.mp3'));
        responseHelper.setSpeechList(goodsSpeech);
        responseHelper.endSession();
        break;
        
      default:
        responseHelper.setSimpleSpeech(clova.SpeechBuilder.createSpeechText(repromptMsg));
        responseHelper.setReprompt(getRepromptMsg(clova.SpeechBuilder.createSpeechText(repromptMsg)));
        break;
    }
  })

  //終了時
  .onSessionEndedRequest(async responseHelper => {
    console.log('onSessionEndedRequest');
  })
  .handle();
  


// オススメの商品をBOTへ送信
async function sendLineBot(userId, jsonData, goodsEn) {
    await client.pushMessage(userId, [
      {
        "type": "flex",
        "altText": "商品を送信しました。",
        "contents": {
          "type": "carousel",
          "contents": await getPlanCarousel(jsonData, goodsEn)
        }
      }
    ]);
}


const getPlanJson = (jsonData, goodsEn) => {
  // LIFFで商品詳細
  const planLiff = "https://liff.line.me/" + process.env.PLAN_LIFF_ID + '?planId=' + jsonData.id;
  // jsonデータから商品を取得
  return {
    "type": "bubble",
    "size": "micro",
    "header": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "size": "sm",
          "text": jsonData.name
        }
      ]
    },
    "hero": {
      "type": "image",
      "url": base_url + jsonData.goodsImageUrl,
      "size": "full",
      "aspectRatio": "20:13",
      "aspectMode": "cover"
    },
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": jsonData.price
        }
      ]
    },
    "footer": {
      "type": "box",
      "layout": "vertical",
      "spacing": "sm",
      "contents": [
        {
          "type": "button",
          "style": "secondary",
          "action": {
            "type": "uri",
            "label": "商品の詳細",
            "uri": planLiff
          }
        },
        {
          "type": "button",
          "style": "primary",
          "action": {
           "type":"postback",
           "label":"商品の購入",
           "data": "action=select&goods="+ goodsEn + "&planId=" + jsonData.id,
           "displayText":jsonData.name + "の商品を購入"
          }
        }
      ]
    },
    "styles": {
      "header": {
        "backgroundColor": "#00ffff"
      },
      "hero": {
        "separator": true,
        "separatorColor": "#000000"
      },
      "footer": {
        "separator": true,
        "separatorColor": "#000000"
      }
    }
  };
};

const getPlanCarousel = async(jsonData, goodsEn) => {
  const planJsons = [];
  const randomAry = await funcRandom(jsonData);
  for (let i = 0; i < 3; i++) {
    planJsons.push(getPlanJson(jsonData[randomAry[i]], goodsEn));
  }
  return planJsons;
};

// ランダム
async function funcRandom(data){
  let arr = [];
  for (let i=0; i<data.length; i++) {
    arr[i] = i;
  }
  let a = arr.length;
 
  // ランダムアルゴリズム
  while (a) {
      let j = Math.floor( Math.random() * a );
      let t = arr[--a];
      arr[a] = arr[j];
      arr[j] = t;
  }
   
  // ランダムされた配列の要素を順番に表示する
  await arr.forEach( function( value ) {} );
  return arr;
}


// リプロント
function getRepromptMsg(speechInfo){
  const speechObject = {
    type: "SimpleSpeech",
    values: speechInfo,
  };
  return speechObject;
}

```
特にClova側のリアクションに音声を入れられるのは初めて知りました。
汎用性が高く、今後も利用できそうです。

```javascript:ClovaSkill.js
clova.SpeechBuilder.createSpeechUrl('URL')
```

## LINEBotの設定
こちらも[書籍](https://www.amazon.co.jp/LINE-API%E5%AE%9F%E8%B7%B5%E3%82%AC%E3%82%A4%E3%83%89-API-Expert-%E8%AA%8D%E5%AE%9A%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC/dp/4839973768/ref=sr_1_1?dchild=1&keywords=line+api&qid=1609290696&sr=8-1)を参考に、Botのコードを編集していきました。

```lineBot.js
'use strict';

const line = require('@line/bot-sdk');
const jsonData = require('../data.json');
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();
const config = {
    channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN || 'test',
    channelSecret: process.env.CHANNEL_SECRET || 'test'
};

const client = new line.Client(config);

module.exports = async( req, res ) => {
  Promise
    .all(req.body.events.map(await handleEvent))
    .then((result) => res.json(result))
    .catch((err) => {
      console.error(err);
      res.status(200).end();
    });
};

// event handler
async function handleEvent(event, session) {
  console.log(event);
  let echo = [];
  const planDataKey = datastore.key(['planData', event.source.userId]);
  
  if (event.type === 'message') {
    if (event.message.text.substring(0, 5) === 'お申し込み') {
      // get Datasore data
      const query = datastore.createQuery('planData').filter('userId', '=', event.source.userId);
      const [planData] = await datastore.runQuery(query);
      var price = '';
      for (const plan of planData) {
        price = plan.planPrice
        console.log(price);
      }
      
      if(price === ''){
        echo = { "type": "text", "text": "申し訳ありませんが、お返事できません。" }; 
      } else{
        // 申し込み内容確認のflex
        echo = {
          "type": "flex",
          "altText": "購入内容を送信しました。",
          "contents": {
            "type": "carousel",
            "contents": [
              {
                "type": "bubble",
                "size": "micro",
                "header": {
                  "type": "box",
                  "layout": "vertical",
                  "contents": [
                    {
                      "type": "text",
                      "text": "お支払い代金"
                    }
                  ]
                },
                "body": {
                  "type": "box",
                  "layout": "vertical",
                  "contents": [
                    {
                      "type": "text",
                      "text": price
                    }
                  ]
                },
                "footer": {
                  "type": "box",
                  "layout": "vertical",
                  "spacing": "sm",
                  "contents": [
                    {
                      "type": "button",
                      "style": "primary",
                      "action": {
                        "type": "uri",
                        "label": "お支払い",
                        "uri": "https://liff.line.me/" + process.env.LINEPAY_LIFF_ID + "?userid=" + event.source.userId
                      }
                    }
                  ]
                },
                "styles": {
                  "header": {
                    "backgroundColor": "#00ffff"
                  }
                }
              }
            ]
          }
        }
      }
    } else {
      echo = { "type": "text", "text": "申し訳ありませんが、お返事できません。" }; 
    }
  } else if (event.type === 'follow') {
    echo = { "type": "text", "text": "オリジナル商品販売スキルを起動してください。カフェマエマエで販売している商品を提案します。" }
    
  } else if(event.type === 'postback'){
    // 埋め込みデータ取得
    const data = new URLSearchParams(event.postback.data);
    const action = data.get('action');
    const result = data.get('result');
    const goods = data.get('goods');
    // 商品購入（選択）
    if (action === 'select') {
      const selData = jsonData[goods].filter(p => p.id == data.get('planId'))[0];
      console.log(selData); 
      
      // Prepares the new entity
      const planData = {
        key: planDataKey,
        data: {
          userId: event.source.userId,
          planId: selData.id,
          planName: selData.name,
          planImageUrl: selData.goodsImageUrl,
          planPrice: selData.price,
        },
      };
     
      // Saves the entity
      await datastore.save(planData);
      
      echo = {
        "type": "flex",
        "altText": "購入内容を送信しました。",
        "contents": {
          "type": "carousel",
          "contents": [
            {
              "type": "bubble",
              "size": "micro",
              "header": {
                "type": "box",
                "layout": "vertical",
                "contents": [
                  {
                    "type": "text",
                    "text": "ご購入の商品"
                  }
                ]
              },
              "body": {
                "type": "box",
                "layout": "vertical",
                "contents": [
                  {
                    "type": "text",
                    "text": selData.name
                  }
                ]
              },
              "footer": {
                "type": "box",
                "layout": "vertical",
                "spacing": "sm",
                "contents": [
                  {
                    "type": "button",
                    "style": "primary",
                    "action": {
                      "type": "uri",
                      "label": "購入情報入力",
                      "uri": "https://liff.line.me/" + process.env.INFO_LIFF_ID
                    }
                  }
                ]
              },
              "styles": {
                "header": {
                  "backgroundColor": "#00ffff"
                }
              }
            }
          ]
        }
      }
    // クイックリプライ
    } else if (action === 'questionnaire') {
      if (result === 'yes') {
        echo = [
          { "type": "text", "text": "ありがとうございました。お会計時に利用できるクーポンを差し上げます。ご利用ありがとうございました。" },
          {
            "type": "flex",
            "altText": "クーポンを送りました。",
            "contents": {
              "type": "bubble",
              "header": {
                "type": "box",
                "layout": "vertical",
                "contents": [
                  {
                    "type": "text",
                    "text": "クーポン",
                    "size": "xl"
                  },
                  {
                    "type": "text",
                    "text": "本日のお会計10%オフ！",
                    "size": "xl",
                    "weight": "bold"
                  },
                  {
                    "type": "text",
                    "text": "お支払い時にスタッフにお見せください。",
                    "weight": "bold",
                    "color": "#ff0000"
                  },
                  {
                    "type": "text",
                    "text": "＊本日のみ利用可能です。"
                  }
                ]
              }
            }
          }
        ];
      } else if (result === 'no') {
        echo = { "type": "text", "text": "申し訳ありませんでした。改善に努めます。ご利用ありがとうございました。" }; 
      } else {
        echo = { "type": "text", "text": "申し訳ありませんが、お返事できません。" }; 
      }
    // リッチメニューの切り替え
    } else if(action === 'campaign'){
      if(goods === 'parker'){
        client.linkRichMenuToUser(event.source.userId, process.env.tshirt_RICHMENU_ID);
        return;
      } else if(goods === 'tshirt'){
        client.linkRichMenuToUser(event.source.userId, process.env.parker_RICHMENU_ID);
        return;
      }
    } else {
      echo = { "type": "text", "text": "申し訳ありませんが、お返事できません。" }; 
    }
  } else {
    echo = { "type": "text", "text": "申し訳ありませんが、お返事できません。" }; 
  }

  // use reply API
  return client.replyMessage(event.replyToken, echo);
}
```
特に、async function handleEventの中にevent.typeによって場合分けを行い、それに応じてreactionを設定している部分は非常に難しく、勉強になりました。（この文章の内容自体が分かるようになっったこと自体が成長www。）

## LINEPayの設定

lineBot.jsから購入内容を受け取り、処理を行います。

```javascript:linePayComfirm.js
'use strict';

/**
「./linePay.js」のファイルは下記から持ってきました。
https://github.com/nkjm/line-pay/blob/v3/module/line-pay.js
*/
const line_pay = require('./linePay.js');
const line = require('@line/bot-sdk');
const config = {
    channelAccessToken: process.env.CHANNEL_ACCESS_TOKEN || 'test',
    channelSecret: process.env.CHANNEL_SECRET || 'test'
};

const pay = new line_pay({
    channelId: process.env.LINEPAY_CHANNEL_ID || 'test',
    channelSecret: process.env.LINEPAY_CHANNEL_SECRET || 'test',
    isSandbox: true
})
const {Datastore} = require('@google-cloud/datastore');
const datastore = new Datastore();

module.exports = async( req, res ) => {
    if (!req.query.transactionId){
        throw new Error('Transaction Id not found.');
    }
    
    // get Datasore data
    const query = datastore.createQuery('planData').filter('transactionId', '=', req.query.transactionId);
    const [planData] = await datastore.runQuery(query);
    var reservation = "";
    for (const plan of planData) {
      reservation = plan.options
    }
    
    // Retrieve the reservation from database.
    if (!reservation){
        throw new Error('Reservation not found.');
    }

    console.log(`Retrieved following reservation.`);
    console.log(reservation);

    let confirmation = {
        transactionId: req.query.transactionId,
        amount: reservation.amount,
        currency: reservation.currency
    }

    console.log(`Going to confirm payment with following options.`);
    console.log(confirmation);

    pay.confirm(confirmation).then(async(response) => {
      
        const client = new line.Client(config);
        await client.pushMessage(reservation.userid, [
        {
          "type": "flex",
          "altText": "お支払いを完了しました。",
          "contents": {
            "type": "bubble",
            "header": {
              "type": "box",
              "layout": "vertical",
              "contents": [
                {
                  "type": "text",
                  "text": "お支払い完了しました。💰",
                  "size": "md",
                  "weight": "bold"
                },
                {
                  "type": "text",
                  "text": "ありがとうございました。🌟",
                  "size": "md",
                  "weight": "bold"
                }
              ]
            }
          }
        },
        {
          "type": "sticker",
          "packageId" : 11537,
          "stickerId" : 52002734
        },
        {
          "type": "text",
          "text": "最後に、商品購入からお支払いまでのお手続きは分かりやすかったですか？",
          "quickReply": {
            "items": [
              {
                "type": "action",
                "action": {
                   "type":"postback",
                   "label":"はい🎵",
                   "data": "action=questionnaire&result=yes",
                   "displayText":"はい🎵"
                }
              },
              {
                "type": "action",
                "action": {
                   "type":"postback",
                   "label":"いいえ😞",
                   "data": "action=questionnaire&result=no",
                   "displayText":"いいえ😞"
                }
              }
            ]
          }
        }]);
    });
    
    // delete
    const planDataKey = datastore.key(['planData', reservation.userid]);
    await datastore.delete(planDataKey);
};
```
requireで他のファイルと繋ぎ、最終的にpayの中に格納して利用。
ここの、変数作成→格納→変数を掛け合わせて使用、の流れはなれないと理解しにくい部分ですが、ここが理解できるようになると、そこに沼が存在することを理解できるようになりました。


## GCPへのデプロイ 
基本的には[書籍](https://www.amazon.co.jp/LINE-API%E5%AE%9F%E8%B7%B5%E3%82%AC%E3%82%A4%E3%83%89-API-Expert-%E8%AA%8D%E5%AE%9A%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC/dp/4839973768/ref=sr_1_1?dchild=1&keywords=line+api&qid=1609290696&sr=8-1)通りに進めば問題ないです。
しかし、初学者にとって何をやっているのかわからない状況で進むのが結構苦痛です。
何度かトライアンドエラーを繰り返す中で、自分なりにGoogle Cloud Platform(GCP)にデプロイするためには以下の手順が必要なのだと理解しました。

①　Google Cloud SDKをインストール
GCPにデプロイするためには、まずGoogle Cloud SDKをインストール必要があります。

```:ターミナルコマンド
$ curl https://sdk.cloud.google.com | bash
```
を行い、対話通り進めば問題ありません。その後に、bashを再起動します。
ちなみにbashとは、人間からパソコンに命令を出すときの変換機といったイメージです。この変換機には数種類あって、この変換機の総称をshellといいます。

```:ターミナルコマンド
$ exec -l $SHELL
```

②　デプロイ先の作成
続いてデプロイ先を作成します。そのための初期化を行うのが以下のコード。

```:ターミナルコマンド
$ gcloud init
```
その後、対話に従いデプロイ先のディレクトリを作成します。

③　デプロイ
デプロイしたいものをビルトします。ディレクトリないのファイル達を荷物として一つにまとめるといった印象を持っています。（あっているかどうかは分かりませんが、個人的な印象です、はい。）

```:ターミナルコマンド
$ gcloud builds submit --tag gcr.io/'デプロイ先のディレクトリ'/'サービス名'
```
続いて先ほどまとめた『荷物』をデプロイ先のディレクトリにお届けします。

```:ターミナルコマンド
gcloud run deploy --image gcr.io/'デプロイ先のディレクトリ'/'サービス名' --platform managed
```

以降は、データを更新する場合は、'built submit'から'run deploy'の部分を行えば、ファイルの更新が可能です。