31
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

結婚式の招待にNode.jsでLinebotを導入してみた話。

Last updated at Posted at 2020-03-02

経緯

いつもはITコンサルティングの会社で働いているTsugaです。
この度結婚することになり、妻と共に式の準備に勤しんでいます。

準備をする中で、ちょっとこの風習は古いんじゃないのか?と思うことがいくつかあったので、自分たちの式では少し工夫をしてみました。

結婚式のここがイケてない

1 招待状

未だに結婚式の招待状は手紙で用意することが前提になっています。

これを業者に丸っと外注すると、非常にお値段的に高くつきますし、自分たちで用意するとなると、それはそれで大変でやりたくありませんでした。

何より、手紙をもらった側も、返信の際は暗黙のしきたりに従って返さねばならず、ちょっと億劫です。

あと、招待状でしか会場の情報や開始時間の情報は送られてこないので、
自分なんかは管理が苦手なので当日になって焦って探したりします。

もう、普通に紙で管理する必要が全くないので電子化したいなと。

2 ご祝儀

これ、自分が参列する時も悩みの種です。

銀行って仕事時間中にしかやってなくて行きづらく、ピン札を用意するのは一苦労。
ご祝儀袋を用意するのも地味に労力使うのです。

自分たちの結婚式に来る人たちにはその苦労をさせたくないし、気持ちよく結婚式に来て欲しい。

だったらもう現金じゃなくて、Webで決済できればよくない??

できたもの

その不満を形にしてできたのがこれです。

以下がポイント。

  1. それ単体では味気ないGoogle formsをBotが送ることで少しだけカッコよくなる。
  2. 会場、時間の情報などにいつでも答えられる
  3. ご祝儀をWebで払え!とは言いづらいので聞かれた時にBotが案内することで角が立たない

招待状を受け取った友人から「自分の式でもこれやりたい!」との声をもらったので、
作り方を残しておきます。

作り方

Line developersに登録する

まず、Line developersに登録します。
https://developers.line.biz/ja/
私は自分の普段使っているLineアカウントで登録しました。

プロバイダーを作る

こんな感じでプロバイダーを作ります。
名前はなんでもいいです。Botの製作者として出るものです。

スクリーンショット 2020-03-01 23.00.11.png

チャネルを作る

プロバイダーを作るとチャネル設定の画面にリダイレクトされます。
スクリーンショット 2020-03-01 23.00.54.png

この中から、今回は真ん中のMessaging APIを選択します。
すると、基本項目を設定するページに飛ぶので、適当に埋めてチャネルを作成してください。

  • 必須項目
    • チャンネルの種類
    • プロバイダー
    • チャネル名(7日後から変更可能)
    • チャネル説明(後から変更可能)
    • 大業種
    • 小業種
    • メールアドレス
  • 任意項目
    • チャネルアイコン
    • プライバシーポリシーURL
    • サービス利用規約URL

チャネルの設定

スクリーンショット 2020-03-01 23.10.15.png

Messaging API設定から、チャネルアクセストークンを発行してください。
どこかにペーストして保存しておいてください。あとで使います。

Line公式アカウント機能は今回使わないので、一通りの機能を無効にします。
応答メッセージかあいさつメッセージの編集ボタンから
Official Account Managerに飛べるので、クリックしてください。

Response Settings

Response Settingsをいじります。

Response modeはBotに、Greeting messageはDisabledにします。
Auto-ResponseはDisabledに、WebhooksはEnabledに変更してください。

スクリーンショット 2020-03-02 0.54.06.png

次に、Messaging API SettingsからWebhook URLの設定欄に飛べますので、
そこからChannel secretの文字列をどこかに保存しておいてください。チャネルアクセストークン同様、あとで使います。

Botサーバの用意

Bot開発ではこの黄色の部分を作ります。
スクリーンショット 2020-03-02 22.51.53.png
今回はNode.js+Expressで作りました。
もうNode開発環境がある方はこの章はすっ飛ばして大丈夫です。

Herokuの準備

Herokuにデプロイするので、Herokuのアカウントを作っておいてください。
https://dashboard.heroku.com/

Herokuは有名なホスティングサービスです。デプロイまでコードをあげるだけで勝手にやってくれるしログもとってくれて本当に楽です。無料枠でも割と普通に使えるので素敵。

こちらのHeroku公式スタートガイドに従っていろいろ初期設定を済ませてください。

もしNode.js, npmが入ってなかったら
このURLから落としてくるのが早いです。
環境に合わせてインストーラを落としてきて、ガイドに従ってインストールしてください。

作業内容は一応書いておくと以下。

> brew install heroku/brew/heroku
> heroku login
> git clone https://github.com/heroku/node-js-getting-started.git
> cd node-js-getting-started
> heroku create
> git push heroku master

これら一連の設定で順当に行けばアプリをデプロイできるはずです(超簡単!)
heroku openで、自分で作ったサイトに飛べます。
こんな感じ。

アプリのURLをコピーして、Line Official Account ManagerのMessaging APIの設定に行って、WebhookURLに設定してください。
https://生成されたドメイン用文字列.herokuapp.com/webhook
今回は/webhookを付けようと思います。(公式チュートリアル準拠)
これで下準備はOKです。

コーディング

Linebot用の公式SDKを入れます。

npm install @line/bot-sdk --save

そんで、こんな感じにLinebotの応答処理を追加します。雛形は以下のような感じ。

 index.js
const express = require('express')
const path = require('path')
const PORT = process.env.PORT || 5000
const line = require('@line/bot-sdk'); // 追加
const config = {
  channelSecret: process.env.SECRET_KEY
,  channelAccessToken: process.env.ACCESS_TOKEN
};
const client = new line.Client(config); // 追加

express()
  .use(express.static(path.join(__dirname, 'public')))
  .set('views', path.join(__dirname, 'views'))
  .set('view engine', 'ejs')
  .get('/', (req, res) => res.render('pages/index'))
  // 以下のPOSTメソッド追加
  .post('/webhook', line.middleware(config), (req, res) => {
    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
  })
  .listen(PORT, () => console.log(`Listening on ${ PORT }`))

// 追加。応答処理
const handleEvent = (event)=> {
  // この中に処理を書きます。
}

configのchannelSecret, channelAccessTokenはLinebotにアクセスするために必要なものです。
Linebotのチャネル設定のところで取りかたは書いているのでご参考に。

そのまま文字列で書くのはセキュリティ的によろしくないので、環境変数から取れるようにしていきます。

heroku config:set SECRET_KEY="取得した文字列"
heroku config:set ACCESS_TOKEN="取得した文字列"

はい、これでheroku上の環境変数はOKです。

ローカルでの開発

Herokuの環境変数にシークレットキーとアクセストークンを仕込みましたが、ローカルでも開発したいと思うのでローカルの環境変数設定をします。
dotenvを使うのが便利です。

npm install dotenv --save

これでdotenvを入れてローカルの.envファイルからprocess.envの設定内容を取得できるようにします。
.envファイルに設定を書きます。

 .env
ACCESS_TOKEN="取得した文字列"
SECRET_KEY="取得した文字列"

そしてindex.jsの頭あたりに.envの読み込み部分を書いておきます。

index.js

require('dotenv').config(); // 追加
...

それで、ローカルでサーバを立てた際に、Lineサーバからアクセスできるように解放しなければなりません。
ngrokや、Localtunnelのようなソフトウェアを使えば、ローカルのサーバをインターネットに公開することができます。
ngrokのインストールはこちら。
Installing ngrok on OSX
LinebotのwebhookURL設定をngrokが生成してくれるURLに書き換えれば、ローカルのサーバで試せます。
スクリーンショット 2020-03-02 22.29.30.png

Botでやりたいこと

  1. フォローしてくれた人に、参加可否アンケートフォームを送ること
  2. 質問に答えられること
  3. 聞かれたときだけご祝儀のWeb決済フォームを送ること

です。まずは1から。

①フォローしてくれた人に参加可否アンケートフォームを送る機能

Linebotでは、Botに対してどんなアクションが取られたかをイベントオブジェクトという形で受け取ることができます。

フォローしてくれた人にたいして何かしらの反応をしたいので、フォローイベントが送られてきた時にだけ、
特定のメッセージを返すような実装をします。

index.js

// メイン処理
const handleEvent = (event)=> {
  switch(event.type) {
    // follow Event時の返答
    case "follow":
      return doReply(event, 
        [getTxtMsg("フォローいただきありがとうございます。こちらからご参加の可否についてご回答をお願いします!"),
        getTxtMsg("https://forms.gle/ホゲホゲ")])
    default:
      break;
  }
}
/**
 * テキストメッセージのオブジェクトを取得する
 * 
 * @param {String} msg テキストメッセージ
 */
const getTxtMsg = (msg) => {
  return {
    "type" : "text",
    "text" : `${msg}`
  }
}
// リプライ処理(Herokuログに残す用) 別になくてもいい
const doReply = (ev, obj) => {
  console.log(ev, obj);
  return client.replyMessage(ev.replyToken, obj)
}

これでgit commitして、git push heroku masterしてBotを確認してみましょう。

BotをLineの友達に追加するためのURLは、Official Account ManagerからGain Friendsのメニューに行くとあります。

スクリーンショット 2020-03-02 22.10.42.png

Followイベントへのレスポンスができました。

②質問に答えられる機能

次に、質問に答えられるようにしていきましょう。
ユーザからの質問はメッセージイベントとしてBotサーバに送られてきます。

クイックリプライ機能

場所は?と聞かれたら、挙式の場所を聞きたいのか、披露宴の場所を聞きたいのかといったことを聞き返そうと思います。
こちらから聞いたことに対して素早く回答してもらうのには、テンプレートメッセージを利用する方法もありますが、今回は自然にLineのやりとりっぽく見えるクイックリプライ機能でリプライにはポストバックアクション(メッセージアクション)を使います。

index.js
// メイン処理
const handleEvent = (event)=> {
  switch(event.type) {
    // follow Event時の返答
    case "follow":
    ...
    // Message Event時の返答
    case "message":
      handleMessageEvent(event);
      break;
    default:
      break;
  }
}

/**
 * メッセージイベントに対する応答を判断する
 * 
 * @param {Object} ev イベントオブジェクト 
 */
const handleMessageEvent = (ev) => {
  switch (ev.message.type) {
    case "text":
      if (isMatchQuickReplyPhrase(ev.message.text)) {
        // クイックリプライでユーザが送ったメッセージには反応しない
        return;
      }
      if (ev.message.text.includes("場所") || ev.message.text.includes("会場")) {
        return doReply(ev, getQuickRpl(
          "挙式(親族)、披露宴(親族・友人)どちらの場所を知りたいですか?",
          [getItm(msg.celemonyPlace.msgText, msg.celemonyPlace.data)
            , getItm(msg.partyPlace.msgText, msg.partyPlace.data)]))
      } else {
        return doReply(ev, txtMsg("対応してないキーワードに対する返答"))
      }
  }
}

/**
 * クイックリプライの対象文言に完全一致するかを判定する
 * @param {string} msgText 
 */
const isMatchQuickReplyPhrase = (msgText) => {
  let keys = Object.keys(msg)
  let isMatch = false
  keys.forEach(elm => {
    if (msgText == msg[elm].msgText) {
      isMatch = true;
    }
  })
  return isMatch;
}
// クイックリプライ用のメッセージ、データ
const msg = {
  "celemonyPlace": {
    "msgText": "挙式はどこで行われますか?",
    "data": "celemonyPlace"
  },
  "partyPlace": {
    "msgText": "披露宴はどこで行われますか?"
    , "data": "partyPlace"
  },
}
/**
 * クイックリプライ用のオブジェクトを取得する
 * 
 * @param {String} txt リプライの文言
 * @param {Object} itms アクションオブジェクト
 */
const getQuickRpl = (txt, itms) => {
  return {
    "type": "text",
    "text": txt,
    "quickReply": {
      "items": itms
    }
  }
}
/**
 * クイックリプライ用のアイテムオブジェクトを取得する
 * 
 * @param {String} txt テキスト
 * @param {String} dt Postbackイベント用データ 
 */
const getItm = (txt, dt) => {
  return {
    "type": "action",
    "action": {
      "type": "postback",
      "label": txt,
      "text": txt,
      "data": dt
    }
  }
}
IMG_1586.jpg

上記のコードではisMatchQuickReplyPhrase関数でクイックリプライで使われるポストバックアクションの文言と完全一致する場合に反応しないようにしています。
次に説明するポストバックイベントだけに反応させたいのですが、これをしないとメッセージイベントとしても反応してしまって要らないレスポンスを返してしまいます。

ポストバックイベントに返答する

ユーザーが使うクイックリプライ用のアイテムオブジェクトをみてもらうとわかりますが、ポストバックイベントにdataというキーがあります。
その内容をみて質問に対する答えを返したいと思います。

index.js

// メイン処理
const handleEvent = (event) => {
  switch (event.type) {
    // follow Event時の返答
    case "follow":
      ...
    // Message Event時の返答
    case "message":
      ...
    // Postback Event時の返答
    case "postback":
      handlePostbackEvent(event);
      break;
    default:
      break;
  }
}
/**
 * ポストバックイベントに対する応答を判断する
 * 
 * @param {Object} ev イベントオブジェクト 
 */
const handlePostbackEvent = (ev) => {
  switch (ev.postback.data) {
    case msg.celemonyPlace.data:
      return doReply(ev, [txtMsg("挙式会場はこちらです。")
      , celemonyPlace])
    case msg.partyPlace.data:
      return doReply(ev, [txtMsg("披露宴会場はこちらです。")
      , partyPlace])
  }
}
// 披露宴会場
const partyPlace =  {
  "type": "location",
  "title": "披露宴会場 ラーメン二郎 三田本店",
  "address": "2 Chome-16-4 Mita, 港区 Minato City, Tokyo 108-0073",
  "latitude": 35.643564,
  "longitude": 139.739017
}
// 挙式会場
const celemonyPlace = {
  "type": "location",
  "title": "挙式会場 湯島天神",
  "address": "〒113-0034 東京都文京区湯島3丁目30−1",
  "latitude": 35.707849,
  "longitude": 139.767824
}

IMG_1587.jpg

しれっとロケーションタイプのメッセージを使っています。
メッセージオブジェクトはいろいろありますが、下記の記事がまとめてくれていて見やすいので是非ご参考にしてください。
https://qiita.com/kakakaori830/items/52e52d969800de61ce28

もうこれらを使えば大体のQ&Aは応用してできるはずです。

③ご祝儀のWeb決済対応

自分では実装しません。Paypalアカウントを作ってください。
送金用のURLを発行して、返すだけです。
一応、ソースコードはこんな感じになります。

index.js

const express = require('express')
const path = require('path')
const PORT = process.env.PORT || 5000
const line = require('@line/bot-sdk');
require('dotenv').config(); // 追加
const config = {
  channelSecret: process.env.SECRET_KEY
  , channelAccessToken: process.env.ACCESS_TOKEN
};
const client = new line.Client(config);

express()
  .use(express.static(path.join(__dirname, 'public')))
  .set('views', path.join(__dirname, 'views'))
  .set('view engine', 'ejs')
  .get('/', (req, res) => res.render('pages/index'))
  .post('/webhook', line.middleware(config), (req, res) => {
    Promise
      .all(req.body.events.map(handleEvent))
      .then((result) => res.json(result));
  })
  .listen(PORT, () => console.log(`Listening on ${PORT}`))

// メイン処理
const handleEvent = (event) => {
  switch (event.type) {
    // follow Event時の返答
    case "follow":
      return doReply(event,
        [getTxtMsg("フォローいただきありがとうございます。こちらからご参加の可否についてご回答をお願いします!"),
        getTxtMsg("https://forms.gle/ホゲホゲ")])
    // Message Event時の返答
    case "message":
      handleMessageEvent(event);
      break;
    // Postback Event
    case "postback":
      handlePostbackEvent(event);
      break;
    default:
      break;
  }
}
/**
 * ポストバックイベントに対する応答を判断する
 * 
 * @param {Object} ev イベントオブジェクト 
 */
const handlePostbackEvent = (ev) => {
  switch (ev.postback.data) {
    case msg.celemonyPlace.data:
      return doReply(ev, [getTxtMsg("挙式会場はこちらです。")
        , celemonyPlace])
    case msg.partyPlace.data:
      return doReply(ev, [getTxtMsg("披露宴会場はこちらです。")
        , partyPlace])
    case msg.onWeb.data:
      return doReply(ev, [getTxtMsg("Webでの決済はPaypal経由でのみ受け付けております。\n以下のURLからお願いいたします!")
        , getTxtMsg("https://paypal.me/ホゲホゲ?locale.x=ja_JP")])
    case msg.onHand.data:
      return doReply(ev, getTxtMsg("お気持ちをありがとうございます!\n当日お渡しの際はピン札のご用意の必要はございません。\n当日は美味しい食事や飲み物をたくさん用意してお待ちしております!"))
    default:
      break;
  }
}
// 披露宴会場
const partyPlace = {
  "type": "location",
  "title": "披露宴会場 ラーメン二郎 三田本店",
  "address": "2 Chome-16-4 Mita, 港区 Minato City, Tokyo 108-0073",
  "latitude": 35.643564,
  "longitude": 139.739017
}
// 挙式会場
const celemonyPlace = {
  "type": "location",
  "title": "挙式会場 湯島天神",
  "address": "〒113-0034 東京都文京区湯島3丁目30−1",
  "latitude": 35.707849,
  "longitude": 139.767824
}

/**
 * メッセージイベントに対する応答を判断する
 * 
 * @param {Object} ev イベントオブジェクト 
 */
const handleMessageEvent = (ev) => {
  switch (ev.message.type) {
    case "text":
      if (isMatchQuickReplyPhrase(ev.message.text)) {
        // クイックリプライでユーザが送ったメッセージには反応しない
        return;
      }
      if (ev.message.text.includes("場所") || ev.message.text.includes("会場")) {
        return doReply(ev, getQuickRpl(
          "挙式、披露宴どちらの場所を知りたいですか?",
          [getItm(msg.celemonyPlace.msgText, msg.celemonyPlace.data)
            , getItm(msg.partyPlace.msgText, msg.partyPlace.data)]))
      } else if (ev.message.text.includes("祝儀") || ev.message.text.includes("祝金")) {
        return doReply(ev
          , [getTxtMsg(`お祝い金は当日のお渡し、\nまたは事前のWebでの決済にていただければ幸いです。`)
            , getQuickRpl("お祝い金のお渡し方法について、Webでの事前決済をご希望ですか?",
              [getItm(msg.onHand.msgText, msg.onHand.data)
                , getItm(msg.onWeb.msgText, msg.onWeb.data)])])
      } else {
        return doReply(ev, getTxtMsg("対応してないキーワードに対する返答"))
      }
  }
}
/**
 * クイックリプライの対象文言に完全一致するかを判定する
 * @param {string} msgText 
 */
const isMatchQuickReplyPhrase = (msgText) => {
  let keys = Object.keys(msg)
  let isMatch = false
  keys.forEach(elm => {
    if (msgText == msg[elm].msgText) {
      isMatch = true;
    }
  })
  return isMatch;
}
// クイックリプライ用のメッセージ、データ
const msg = {
  "celemonyPlace": {
    "msgText": "挙式はどこで行われますか?",
    "data": "celemonyPlace"
  },
  "partyPlace": {
    "msgText": "披露宴はどこで行われますか?"
    , "data": "partyPlace"
  },
  "onHand": {
    "msgText": "当日手渡しをします",
    "data": "onHand"
  },
  "onWeb": {
    "msgText": "Web決済を希望します",
    "data": "onWeb"
  }
}
/**
 * クイックリプライ用のオブジェクトを取得する
 * 
 * @param {String} txt リプライの文言
 * @param {Object} itms アクションオブジェクト
 */
const getQuickRpl = (txt, itms) => {
  return {
    "type": "text",
    "text": txt,
    "quickReply": {
      "items": itms
    }
  }
}
/**
 * クイックリプライ用のアイテムオブジェクトを取得する
 * 
 * @param {String} txt テキスト
 * @param {String} dt Postbackイベント用データ 
 */
const getItm = (txt, dt) => {
  return {
    "type": "action",
    "action": {
      "type": "postback",
      "label": txt,
      "text": txt,
      "data": dt
    }
  }
}
/**
 * テキストメッセージのオブジェクトを取得する
 * 
 * @param {String} msg テキストメッセージ
 */
const getTxtMsg = (msg) => {
  return {
    "type": "text",
    "text": `${msg}`
  }
}

// リプライ処理(Herokuログに残す用)
const doReply = (ev, obj) => {
  console.log(ev, obj);
  return client.replyMessage(ev.replyToken, obj)
}

結果はこちら。
IMG_1588.jpg

改めて以下のURLから今回のボットのサンプルに触れるようにしておきます。
https://lin.ee/lFWuSeZ

今回ハードコーディングで全て実装しました。我々の結婚式本番で使っているBotはもう少しいろいろきめ細やかに作り込んでいますが、
一通り触って見て、LinebotはとてもSDKも使いやすく、できることもわかりやすいため開発しやすいなと思いました。

しかし、もう少し賢く応答してくれるBotを作ろうと思うと、これでは限界があります。
賢いBotはDialog Flowなどチャットボット作成に向いたサービスがあるのでそっちの方が良さそうです。

これから2ヶ月後くらいに結婚式本番なので、そこでもさらにLinebotを活かした余興ができないか試してみようと思います。

後日談 新型コロナの影響で延期

4月中の式を予定しておりましたが、家族にも友人にも感染リスクを負わせたくないため延期を決定しました。
そこで役に立った機能がこちらのブロードキャストメッセージ機能
リンクの内容はBotサーバから送るものですが、そんなことしなくてもBroadcastsはコンソールから送れるのでそっちのが楽です。

LineのOfficial Account Managerにログインして、Broadcals list⇒Create Newを押下すればメッセージ作成画面に飛べます。
image.png
botに参加していただいている参列者全員に延期の案内を送ることができました。
再度開催の案内を送るのもBot経由でできるので、延期したときに真価を発揮すると感じます。(泣)

さらに後日談

昨年10月に無事に結婚式は開催することができました。
Linebotを使えば、後日体調が悪くなってないか聞き取りもできます。
このご時世、結婚式の予定も変わりうる上に細かいコミュニケーションとケアが必要な中、
Linebotで参加者とつながることは有力な選択肢となると思います。

31
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?