エンジニアがLINEとGASで結婚式の参加者体験を向上させようとした話

一応エンジニアの端くれでありますので、せっかくなら自分が軽くできる範囲内で参加者体験を向上させられればと思いやってみました。

# やったこと

LINEの公式アカウントを新規作成し、その中で以下のことを実現しました。

* WEB招待状、タイムスケジュール、席次表、メニューなどを確認できるようにした
* 特定のキーワードを話しかけると、それに対して返答をするBOTを作成した
* 公式アカウントに画像を送信すると、GoogleDriveに画像がアップロードされるようにした

## 構成はこんな感じ
![LINE構成.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/1bb7cb0e-fa8a-9287-3198-b30a689e4a7a.jpeg)

（簡素すぎる図に我ながらアウトプットのセンスの無さを恥じます）

# 準備したもの

* LINE公式アカウント
* Googleアカウント

以上！（準備簡単！エコ！）

# 作成の流れ

## LINE公式アカウントの設定変更＠ビジネス

LINE公式アカウントで、まずはMessenger API連携できるようBOTの設定をします。

https://manager.line.biz/

### 利用する公式アカウントを選択

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/6f1f11de-3b90-10ab-81a0-ba5ea84fe82a.png)

### 設定変更

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/f5e40e27-b8ea-67c3-e082-e90329d7c8c9.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/b85dd88e-c669-c843-3d83-5987dfcebafd.png)

## LINE公式アカウントの設定変更＠Developers

### コンソールを開く
https://developers.line.biz/ja/
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/0ac3ccd8-6914-30e9-e4f9-d78d4e238511.png)

### プロバイダを作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/0f2a6eea-893b-b2fa-686d-e5010e617d14.png)

### チャネルを作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/5b99b835-2b05-76d4-288f-82fcd7c2bf2a.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/1b815222-62f2-f8a9-f17b-990b24e59b80.png)

### 色々入力してチャネルを作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/7eddc6bf-174b-8061-536c-59634730405a.png)


### MessagingAPI設定を変更
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/5bcf8b64-0b30-bca1-b33e-b79337cc83ba.png)

### チャンネルトークンの発行
「Channel access token」で「issue」からアクセストークンを発行します。
発行したトークンはコピーしておいてください。

※参考画像のトークンはサンプルです
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/518e2278-792d-af81-99b9-f0d5c2e9b001.png)

## GoogleDriveのフォルダ作成
### フォルダを作成します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/caf2869f-dddf-37c4-7e25-866dadd34a26.png)

### フォルダのIDを取得
作成したフォルダを開いて、URLからフォルダIDをコピーします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/2236efec-293f-4e49-9257-b2ff86ffb84d.png)

## SpreadSheet作成

### 使用するシート作成 
使用するGoogleSpreadSheetを新規作成し、「faq」「maybe」「debug」のシートを作成します。後でGoogleAppScript（以降GAS）のスクリプト内で使います。

![スクリーンショット 2022-12-04 22.09.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/81a2cb4e-e39c-d5a1-23db-4f89393fff29.png)

### 「faq」シートにサンプル記載
faqシートは、A列にbotへの質問、B列に返答を記載するシートです。
（また、botへの質問はA列に記載した内容に部分一致する物が判定されます）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/12f74aa2-19f5-7cbb-8c73-27dfc2470268.png)

### 「maybe」シートにサンプル記載
maybeシートは、質問の「表記揺れ」を判定するためのシートです。
A列にB列の可能性がある表記揺れを記載します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/339d8c4c-67ee-aef6-b8e9-7d66e3a609d9.png)

### 「debug」シートに項目記載
debugシートは、メッセージへのアクセスログを記録するシートです。
項目名を記載します。

![スクリーンショット 2022-12-04 22.24.52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/e1aebc0c-6458-57fb-7497-d3566a9181d6.png)

### スプレッドシートのIDを取得
SpreadSheetのURLからシートのIDを取得します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/52d5e4b5-0af9-8505-d0b3-3b521bc16c9d.png)


## GAS作成
作成したSpreadSheetから「Apps Script」を選択し、GASを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/36e03cd7-cde9-fc29-cc73-50802d1b7ef1.png)

### スクリプト記載

以下のスクリプトを記載します。ここまでで準備したIDやらURLを中で変更しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/0404920d-8c83-de34-2c0e-5463e9638703.png)


```
/**
 * LINE連携用定数
 */
// 利用しているシート
const SHEET_ID = '（SpreadSheetのIDをはる）';
// 利用しているSSのシート名（※変えるとみえなくなる）
const SHEET_NAME = 'faq';
// 利用しているもしかしてSSのシート名（※変えるとみえなくなる）
const SHEET_NAME_MAYBE = 'maybe';

// LINE Message API アクセストークン
const ACCESS_TOKEN = '（LINEのアクセストークンをはる）';
// 通知URL
const PUSH = "https://api.line.me/v2/bot/message/push";
// リプライ時URL
const REPLY = "https://api.line.me/v2/bot/message/reply";
// プロフィール取得URL
const PROFILE = "https://api.line.me/v2/profile";

/**
 * google drive 連携用定数
 */
const GOOGLE_DRIVE_FOLDER_ID = '（GoogleDriveのフォルダIDをはる）';
const GOOGLE_DRIVE_FOLDER_URL = '（GoogleDriveのフォルダURLをはる）';

/**
 * doPOST
 * POSTリクエストのハンドリング
 */
function doPost(e) {
  const json = JSON.parse(e.postData.contents);
  reply(json);
}

/** 
 * doGet
 * GETリクエストのハンドリング
 */
function doGet(e) {
    return ContentService.createTextOutput("SUCCESS");
}

/** 
 * reply
 * ユーザからのアクションに返信する
 */
function reply(data) {
  // POST情報から必要データを抽出
  const lineUserId = data.events[0].source.userId;
  const postMsg    = data.events[0].message.text;
  const postType    = data.events[0].message.type;
  const replyToken = data.events[0].replyToken;
  // 記録用に検索語とuserIdを記録
  debug(postMsg, lineUserId, postType);

  if (postType === 'image') {
    const messageId = data.events[0].message.id;
    const lineEndPoint = "https://api-data.line.me/v2/bot/message/" + messageId + "/content";
    //変数LINE_END_POINTとreply_tokenを関数getImageに渡し、getImageを起動する
    const imageResult = getImage(lineEndPoint, replyToken, lineUserId);
    sendMessage(replyToken, 'ありがとうございます！皆さんからいただいた写真はこちら→ ' + GOOGLE_DRIVE_FOLDER_URL);
  } else if(postType === 'text') {
    if (
      postMsg === 'タイムスケジュール'
      || postMsg === '席次表'
      || postMsg === 'メニュー表'
      || postMsg.indexOf(GOOGLE_DRIVE_FOLDER_URL) !== -1
      || postMsg.indexOf('写真をとったらこのLINE') !== -1
    ) {
      return;
    }

    // 検索語に対しての回答をSSから取得
    const answers = findResponseArray(postMsg);

    // 回答メッセージを作成
    let replyText = '「' + postMsg + '」ですね。新郎新婦からお返事です。';
    // 回答の有無に応じて分岐
    if (answers.length === 0) {
      // 「類似の検索キーワード」がないかチェック
      const mayBeWord = findMaybe(postMsg);
      if (typeof mayBeWord === "undefined") {
        // 回答がない場合の定型文
        sendMessage(replyToken, '答えが見つかりませんでした。別のキーワードで質問してみてください。名前や短い単語の方が引っかかるかも？');        
      } else {
        sendMayBe(replyToken, mayBeWord);
      }
    } else {
      // 回答がある場合のメッセージ生成
      answers.forEach(function(answer) {
        replyText = replyText + "\n\n＝＝＝＝＝＝＝＝＝＝＝＝＝\n\nQ：" + answer.key + "\n\nA：" + answer.value;
      });

      // 1000文字を超える場合は途中で切る
      if (replyText.length > 1000) {
        replyText = replyText.slice(0,1000) + "……\n\n＝＝＝＝＝＝＝＝＝＝＝＝＝\n\n回答文字数オーバーです。詳細に検索キーワードを絞ってください。";
      }
      // メッセージAPI送信
      sendMessage(replyToken, replyText);
    }
  } else {
      // メッセージAPI送信
      sendMessage(replyToken, '申し訳ありません。画像又はテキストでのメッセージを送信してください。');
  }
}

// SSからデータを取得
function getData() {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
  var data = sheet.getDataRange().getValues();

  return data.map(function(row) { return {key: row[0], value: row[1], type: row[2]}; });
}

// SSから「もしかして」データを取得
function getMayBeData() {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME_MAYBE);
  var data = sheet.getDataRange().getValues();
  return data.map(function(row) { return {key: row[0], value: row[1], type: row[2]}; });
}

// 単語が一致したセルの回答を配列で返す
function findResponseArray(word) {
  // スペース検索用のスペースを半角に統一
  word = word.replace('　',' ');
  // 単語ごとに配列に分割
  var wordArray = word.split(' ');
  return getData().reduce(function(memo, row) {
    // 値が入っているか
    if (row.value) {
      // AND検索ですべての単語を含んでいるか
      var matchCnt = 0;
      wordArray.forEach(function(wordUnit) {
        // 単語を含んでいればtrue
        if (row.key.indexOf(wordUnit) > -1) {
          matchCnt = matchCnt + 1;
        }
      });
      if (wordArray.length === matchCnt) {
        memo.push(row);
      }
    }
    return memo;
  }, []) || [];
}

// 単語が一致したセルの回答を「もしかして」を返す
function findMaybe(word) {
  return getMayBeData().reduce(function(memo, row) { return memo || (row.key === word && row.value); }, false) || undefined;
}

// 画像形式でAPI送信
function sendMessageImage(replyToken, imageUrl) {
  // replyするメッセージの定義
  var postData = {
    "replyToken" : replyToken,
    "messages" : [
      {
        "type": "image",
        "originalContentUrl": imageUrl
      }
    ]
  };
  return postMessage(postData);
}

// LINE messaging apiにJSON形式でデータをPOST
function sendMessage(replyToken, replyText) {  
  // replyするメッセージの定義
  var postData = {
    "replyToken" : replyToken,
    "messages" : [
      {
        "type" : "text",
        "text" : replyText
      }
    ]
  };
  return postMessage(postData);
}

// LINE messaging apiにJSON形式で確認をPOST
function sendMayBe(replyToken, mayBeWord) {  
  // replyするメッセージの定義
  var postData = {
    "replyToken" : replyToken,
    "messages" : [
      {
        "type" : "template",
        "altText" : "もしかして検索キーワードは「" + mayBeWord + "」ですか？",
        "template": {
          "type": "confirm",
          "actions": [
            {
                "type":"message",
                "label":"はい",
                "text":mayBeWord,
            },
            {
                "type": "message",
                "label": "いいえ",
                "text": "いいえ、違います。"
            }
          ],
          "text": "答えが見つかりませんでした。もしかして検索キーワードは「" + mayBeWord + "」ですか？"
        }

      }
    ]
  };
  return postMessage(postData);
}

// LINE messaging apiにJSON形式でデータをPOST
function postMessage(postData) {  
  // リクエストヘッダ
  var headers = {
    "Content-Type" : "application/json; charset=UTF-8",
    "Authorization" : "Bearer " + ACCESS_TOKEN
  };
  // POSTオプション作成
  var options = {
    "method" : "POST",
    "headers" : headers,
    "payload" : JSON.stringify(postData)
  };
  return UrlFetchApp.fetch(REPLY, options);      
}

/** ユーザーのアカウント名を取得
 */
function getUserDisplayName(userId) {
  var url = 'https://api.line.me/v2/bot/profile/' + userId;
  var userProfile = UrlFetchApp.fetch(url,{
    'headers': {
      'Authorization' :  'Bearer ' + ACCESS_TOKEN,
    },
  })
  return JSON.parse(userProfile).displayName;
}

// userIdシートに記載
function lineUserId(userId) {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('userId');
  sheet.appendRow([userId]);
}

// debugシートに値を記載
function debug(text, userId, postType) {
  var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('debug');
  var date = new Date();
  var userName = getUserDisplayName(userId);
  sheet.appendRow([userId, userName, text, Utilities.formatDate( date, 'Asia/Tokyo', 'yyyy-MM-dd HH:mm:ss'), postType]);
}

//Blob形式で画像を取得する
function getImage(lineEndPoint, reply_token, lineUserId){
  // ファイル名に使う日時
  const date = new Date();
  const formattedDate = Utilities.formatDate(date, 'Asia/Tokyo', 'yyyyMMddHHmmss');

  try {
    var headers = {
      "Content-Type": "application/json; charset=UTF-8",
      "Authorization": "Bearer " + ACCESS_TOKEN
    };

    const options = {
      "method" : "get",
      "headers" : headers,
    };

    const res = UrlFetchApp.fetch(lineEndPoint, options);

    //Blob形式で画像を取得し、ファイル名を設定する
    //ファイル名: LINE画像_YYYYMMDD_HHmmss.png
    var imageBlob = res.getBlob().getAs("image/png").setName("式中画像_" + formattedDate + ".png")

    //変数imageBlobとreply_tokenを関数saveImageに渡し、saveImageを起動する
    saveImage(imageBlob, lineUserId);
  } catch(e) {
    //例外エラーが起きた時にログを残す
    Logger.log(e.message);
  }
}

//画像をGoogle Driveのフォルダーに保存する
function saveImage(imageBlob, lineUserId){
  try{
    var folder = DriveApp.getFolderById(GOOGLE_DRIVE_FOLDER_ID);
    var file = folder.createFile(imageBlob);
    return folder.getName();  
  } catch(e){
    return 'ドライブへの保存に失敗しました'
  }
}
```

### GASをウェブアプリケーションとして公開
デプロイを行なって公開します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/498d25e6-8d73-88f9-daec-0ad628b31c9d.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/58e26961-90a5-4ce0-6d50-d3dcff749568.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/5be250ed-79ea-9b06-0c62-ace8a60294dd.png)

途中この辺で認証許可がありますが、認証するを選択して進みます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/0afc071e-9c86-90fb-237b-4abd58fad1f1.png)

デプロイ後に表示されるURLをコピーします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/49cbc8cf-a91a-9df3-67f1-a339e45365d1.png)

## LINEにwebhook URLを設定
デプロイで作成したURLをLINE Deverlopersの設定画面で、設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/1a76ec92-0cea-fab6-e5ba-5a682de91a85.png)

# 成果物

## 特定のキーワードに対して返答をするBOT
こんな感じでBOTが返答してくれます。式中は、参加者一人一人の方への「名前」⇨「メッセージ」みたいな感じでメッセージカードプラスアルファみたいな使い方もしていました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/f012894d-7c50-b2f9-0b77-2284e4bce311.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/f9770b22-9bb6-4128-1c15-95b36da9b9b1.png)

## 公式アカウントに画像を送信すると、GoogleDriveに画像がアップロードされる

### 送信した際の返答
こんな感じで保存された際に返信を行います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/b4a46098-d1e4-77fe-32f8-e3075eed2d6e.png)

### Driveに保存されたところ
こんな感じで保存されていきます。Drive自体の公開制限を式中とか限定で公開にしておけば、参加者の方も表示ができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/215128/002aa289-a72f-8fd2-1bd1-14c6b59f348a.png)

## WEB招待状、タイムスケジュール、席次表、メニューを確認できるようにした

（ゴメンナサイ、力尽きたので後日更新にします）

# 振り返り
## Keep
* 式中のコミュニケーションのきっかけになった（と思っている）
* 写真の共有を参加者の方とできた（と思っている）
* 作る過程そのものが面白かった、新郎新婦内での会話になった

## Problem
* 導線・使い方が結構わかりづらかった様子（写真のアップロードは特に）
* BOTの返答作成はもっとボリュームを用意した方が良さそうだった
* BOTの返答キーワードが見つけづらかった様子（2〜3個で諦めてしまう）

## Try
次があるというわけではないけれど。。。
* チュートリアルみたいな導線は作っておく
* 作ったチュートリアルの導線は、事前に一斉送信とかする

# シンプルに感想
そもそもとして、仕事で使っているスキル（と言えるほどではないが）を、使って一つのサービス（と言えるほどではないが）を作ってみたのはとても面白い経験でした。これからも思いつきで色々と試してみたいのと、もしかしたら誰かのを手伝ってみたいなとか調子に乗ってみたりとかもしています。