【GAS/LINE WORKS】選択式のフォームを作る

GAS(Google Application Script)に戻る

目次

概要

アンケートを作成する際など、選択式のメッセージを送信したい時などある。
そういうときはボタンテンプレートのメッセージを送るようにする。

ソースコード

全体像だが、もっとわかりやすく整理する余地あり。

定数定義部分

const HeaderString = {
  messageId: "MessageID",
  userId: "UserId",
  messageType: "MessageType",
  dateTime: "StartDateTime",
  question1: "Question1",
  question2: "Question2",
  question3: "Question3"
}

const SheetNames = {
  main: "Main",
  shopList: "shop_list"
}

const postBack = "ButtonTemplate_Shop_List";

メイン部分

// POSTリクエストに対する処理
function doPost(e) {
  // JSONをパース
  if (e == null || e.postData == null || e.postData.contents == null) {
    console.log("error");
    return;
  }
  
  const ss = SpreadsheetApp.getActive()
  const sheet = ss.getSheetByName(SheetNames.main);
  var lastRow = sheet.getLastRow()
  var lastColumn = sheet.getRange(lastRow, sheet.getMaxColumns()).getNextDataCell(SpreadsheetApp.Direction.PREVIOUS).getColumn();
  const headers = sheet.getRange(1,1,1,sheet.getLastColumn()).getValues()[0];

  if (lastColumn >= headers.length) {
    lastRow += 1
    lastColumn = 1
  }

  const requestJSON = e.postData.contents;
  const requestObj = JSON.parse(requestJSON);

  switch(headers[lastColumn]) {
    case HeaderString.messageId: 
    case HeaderString.userId:
    case HeaderString.messageType:
    case HeaderString.dateTime:
      var values = [];
      for (i in headers){
        var val = "";
        switch(headers[i]) {
          case HeaderString.messageId:
            val = lastRow - 1;
            break;
          case HeaderString.userId:
            val = "373737";
            break;
          case HeaderString.messageType:
            val = requestObj.type;
            break;
          case HeaderString.dateTime:
            val = new Date();
            break;
          default:
            break;
        }
        values.push(val);
      }
      sheet.appendRow(values);
      questionShopList("");
      break;
    case HeaderString.question1:
      const postback = requestObj.content.postback;
      if (postback == postBack) {
        sheet.getRange(lastRow, lastColumn + 1).setValue(requestObj.content.text);
        sendMessage("あなたの名前を教えてください。");
      } else {
        questionShopList(requestObj.content.text);
      }
      break;
    case HeaderString.question2:
      sheet.getRange(lastRow, lastColumn + 1).setValue(requestObj.content.text);
      sendMessage("どのようなことでお困りでしょうか?");
      break;
    case HeaderString.question3:
      sheet.getRange(lastRow, lastColumn + 1).setValue(requestObj.content.text);
      sheet.getRange(lastRow, 8).setValue("質問完了");
      sendMessage("どうもありがとうございました。");
      break;
  }
}

function sendMessage(message) {
  const accessToken = getAccessToken().access_token;
  const url = `https://www.worksapis.com/v1.0/bots/${configureData.botId}/channels/${configureData.channelId}/messages`
  if (typeof message == 'string') {
    const options = {
      method: 'post',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      },
      payload: JSON.stringify({
        'content': {
          'type': 'text',
          "text": message
        }
      })
    }

    try {
      const response = UrlFetchApp.fetch(url, options);
      Logger.log(response)
    } catch(e) {
      // 例外エラー処理
      Logger.log('Error(messages):')
      Logger.log(e)
    }
  }
}

function questionShopList(inputText) {
  const ss = SpreadsheetApp.getActive()
  const sheet = ss.getSheetByName(SheetNames.shopList)
  const shopListData = sheet.getRange(1,1,sheet.getLastRow(),3).getValues();
  const shopListKana = shopListData.filter(value => value[2].match(inputText)).splice(0,10);

  var questionText = "所属店舗はどちらですか?\n(頭文字を入力すると検索が部分一致検索ができます)";
  var actions = [];
  
  if (inputText == "") {
    for (index in shopListData.splice(0,10)) {
      const action = {
        "type": "message",
        "label": shopListData[index][1],
        "postback": postBack
      };
      actions.push(action);
    }
  } else {
    if (shopListKana.length > 0) {
      shopListKana.forEach(function(shop){
        const action = {
          "type": "message",
          "label": shop[1],
          "postback": postBack
        };
        actions.push(action);
      })
    } else {
      questionText = "一致する店名がありませんでした。もう一度入力してください。"
      for (index in shopListData.splice(0,10)) {
        const action = {
          "type": "message",
          "label": shopListData[index][1],
          "postback": postBack
        };
        actions.push(action);
      }
    }
  }

  const accessToken = getAccessToken().access_token;
  const url = `https://www.worksapis.com/v1.0/bots/${configureData.botId}/channels/${configureData.channelId}/messages`
    const options = {
      method: 'post',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      },
      payload: JSON.stringify({
        "content": {
          "type": "button_template",
          "contentText": questionText,
          "actions": actions
        }
      })
    }

    try {
      const response = UrlFetchApp.fetch(url, options);
      Logger.log(response)
    } catch(e) {
      // 例外エラー処理
      Logger.log('Error(messages):')
      Logger.log(e)
    }
}

トークンなど

const configureData = {
  clientiD: [Client ID],
  clientSecret: [Client Secret],
  serviceAccount: "Service Account",
  privateKey: "-----BEGIN PRIVATE KEY-----\n[Private Key]\n-----END PRIVATE KEY-----",
  botId: "[Bot ID]",
  channelId: "[チャンネルID]"
}

const getAccessToken = () => {
  const time = Date.now();
  const header = Utilities.base64Encode(JSON.stringify({ 'alg': 'RS256', 'typ': 'JWT' }));
  const claimSet = Utilities.base64Encode(JSON.stringify({
    'iss': configureData.clientiD,
    'sub': configureData.serviceAccount,
    'iat': Math.floor(time / 1000),
    'exp': Math.floor(time / 1000 + 3600)
  }));

  const privateKey = configureData.privateKey;
  
  const signature = Utilities.base64Encode(
    Utilities.computeRsaSha256Signature(
      `${header}.${claimSet}`,
      privateKey
      )
    );
  const jwt = `${header}.${claimSet}.${signature}`;

  const endpoint = 'https://auth.worksmobile.com/oauth2/v2.0/token';
  const options = {
    method: 'post',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    payload: {
      'assertion': jwt,
      'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
      'client_id': configureData.clientiD,
      'client_secret': configureData.clientSecret,
      'scope': 'bot,bot.read'
    }
  }
  try {
    const res = JSON.parse(UrlFetchApp.fetch(endpoint, options));
    Logger.log(res)
    return res;
  } catch(e) {
    // 例外エラー処理
    Logger.log('Error(token):')
    Logger.log(e)
    return "";
  }
}

デモ動画

詳細

選択式のフォームメッセージの実装部分

function questionShopList(inputText) {
  const ss = SpreadsheetApp.getActive()
  const sheet = ss.getSheetByName(SheetNames.shopList)
  const shopListData = sheet.getRange(1,1,sheet.getLastRow(),3).getValues();
  const shopListKana = shopListData.filter(value => value[2].match(inputText)).splice(0,10);

  var questionText = "所属店舗はどちらですか?\n(頭文字を入力すると検索が部分一致検索ができます)";
  var actions = [];
  
  if (inputText == "") {
    for (index in shopListData.splice(0,10)) {
      const action = {
        "type": "message",
        "label": shopListData[index][1],
        "postback": postBack
      };
      actions.push(action);
    }
  } else {
    if (shopListKana.length > 0) {
      shopListKana.forEach(function(shop){
        const action = {
          "type": "message",
          "label": shop[1],
          "postback": postBack
        };
        actions.push(action);
      })
    } else {
      questionText = "一致する店名がありませんでした。もう一度入力してください。"
      for (index in shopListData.splice(0,10)) {
        const action = {
          "type": "message",
          "label": shopListData[index][1],
          "postback": postBack
        };
        actions.push(action);
      }
    }
  }

  const accessToken = getAccessToken().access_token;
  const url = `https://www.worksapis.com/v1.0/bots/${configureData.botId}/channels/${configureData.channelId}/messages`
    const options = {
      method: 'post',
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'Content-Type': 'application/json'
      },
      payload: JSON.stringify({
        "content": {
          "type": "button_template",
          "contentText": questionText,
          "actions": actions
        }
      })
    }

    try {
      const response = UrlFetchApp.fetch(url, options);
      Logger.log(response)
    } catch(e) {
      // 例外エラー処理
      Logger.log('Error(messages):')
      Logger.log(e)
    }
  }
}

ひとつひとつ見ていこう。
手順で言えば、以下の通りだ。

  • すでに記載しているシートのリストからデータを取得する
  • 初回のボタンテンプレートメッセージかどうか判別する
  • 送られてきたメッセージが質問に対する回答か、単なるメッセージか判別する
    • 単なるメッセージの場合、部分検索と判定して絞り込みを行う
    • 質問に対する回答だった場合、シートにデータを記載する
  • データを元に選択肢のデータを作成する

すでに記載しているシートのリストからデータを取得する

まずは、選択のリストを取得する。
以下のように記載したシートを配列にする。

それが以下の部分だ。

  • データを入力しているシートを取得する
  • シートに入力されている特定の範囲を配列にする
const ss = SpreadsheetApp.getActive()
const sheet = ss.getSheetByName(SheetNames.shopList)
const shopListData = sheet.getRange(1,1,sheet.getLastRow(),3).getValues();
const shopListKana = shopListData.filter(value => value[2].match(inputText)).splice(0,10);
  1. shop_listのシートを取得
  2. getRangeメソッドを使用して、1行目の1列目を始点として、行に関しては入力されている最後の行(LastRow)まで、列に関しては始点から3列の範囲を取得(getRange)する。

初回のボタンテンプレートメッセージかどうか判別する

該当箇所は以下。

var questionText = "所属店舗はどちらですか?\n(頭文字を入力すると検索が部分一致検索ができます)";
var actions = [];

if (inputText == "") {
  for (index in shopListData.splice(0,10)) {
    const action = {
      "type": "message",
      "label": shopListData[index][1],
      "postback": postBack
    };
    actions.push(action);
  }
} else {
  if (shopListKana.length > 0) {
    shopListKana.forEach(function(shop){
      const action = {
        "type": "message",
        "label": shop[1],
        "postback": postBack
      };
      actions.push(action);
    })
  } else {
    questionText = "一致する店名がありませんでした。もう一度入力してください。"
    for (index in shopListData.splice(0,10)) {
      const action = {
        "type": "message",
        "label": shopListData[index][1],
        "postback": postBack
      };
      actions.push(action);
    }
  }
}
// 初回で呼び出す時
questionShopList("");

// なんかの入力を返事に反映させる場合
questionShopList(requestObj.content.text);

上記のように、初回で呼び出す時は長さ0の文字列を引数に設定し、それ以外は送信したメッセージを引数に渡すように実装している。

for (index in shopListData.splice(0,10)) {
  const action = {
    "type": "message",
    "label": shopListData[index][1],
    "postback": postBack
  };
  actions.push(action);
}

上記のように初回の場合はshopListDataの10項目を取得する。

const shopListKana = shopListData.filter(value => value[2].match(inputText)).splice(0,10);

shopListKana.forEach(function(shop){
  const action = {
    "type": "message",
    "label": shop[1],
    "postback": postBack
  };
  actions.push(action);
})

何かのメッセージが送られてきた場合は、上記のようにshopListKanaのリストにinputText(メッセージの内容)と一致するもののみを取得するようにしている。

送られてきたメッセージが質問に対する回答か、単なるメッセージか判別する

case HeaderString.question1:
  const postback = requestObj.content.postback;
  if (postback == postBack) {
    sheet.getRange(lastRow, lastColumn + 1).setValue(requestObj.content.text);
    sendMessage("あなたの名前を教えてください。");
  } else {
    questionShopList(requestObj.content.text);
  }
  break;

requestObj.content.postback」というオブジェクトを見て判断する。
こちらは後述するが、選択肢のデータを作成する際、「postback」というパラメータを設定している。
これは、選択肢のボタンが押された時にメッセージを送るのと併せてデータ別のを載せることができる。
ここのpostbackが設定されているかどうかで質問に回答したのか、それとも単にメッセージを送ったのかを判別する

参考ページ

GAS(Google Application Script)に戻る