そのうち誰かの役に立つ

もしくは誰の役にも立たない

Google Apps Script(GAS)とLINE Messaging APIでユーザアカウント不要のイベント受付システムを作る -5- まとめ

続きもの。

  1. 準備
  2. Googleフォームの回答と編集URLを取得する
  3. Googleドキュメントでチケットを作成する
  4. Gmailでメールを送信したりLINEにPushメッセージを送ったりする
  5. まとめ ←今ココ

まとめと称して色々と思ったところを書く。

成果物

前記事までで紹介したコード片をまとめると以下のようになる。

長いので折り畳み

// チケット格納フォルダID
// 共有設定でリンクを知っている人のみが閲覧可能にしておく
var ticket_folder_id = PropertiesService.getScriptProperties().getProperty("FOLDER_ID") || "";
// チケットヘッダ画像オブジェクトID
var ticket_header_image_id = PropertiesService.getScriptProperties().getProperty("HEADER_ID") || "";
// チケットフッタ画像オブジェクトID
var ticket_footer_image_id = PropertiesService.getScriptProperties().getProperty("FOOTER_ID") || "";
// チケット管理用スプレッドシートオブジェクトID
var ticket_sheet_id = PropertiesService.getScriptProperties().getProperty("TICKET_SHEET_ID") || "";
// LINE Botのアクセストークン
var access_token = PropertiesService.getScriptProperties().getProperty("ACCESS_TOKEN") || "";
// LINEグループのグループID
var group_id = PropertiesService.getScriptProperties().getProperty("GROUP_ID") || "";
// GoogleフォームのオブジェクトID
var form_id = PropertiesService.getScriptProperties().getProperty("FORM_ID") || "";

function createTicket(answers) {
  // チケット用Googleドキュメントの作成
  var title = answers["お名前"] + "様チケット"
  var tmp_doc = DocumentApp.create(title);
  var ticket_id = DriveApp.getFileById(tmp_doc.getId()).makeCopy(DriveApp.getFolderById(ticket_folder_id)).getId();
  DriveApp.removeFile(DriveApp.getFileById(tmp_doc.getId()));
  var ticket_doc = DocumentApp.openById(ticket_id);
  ticket_doc.setName(title);
    
  var ticket_body = ticket_doc.getBody();
  
  // ヘッダ画像挿入
  if (ticket_header_image_id != "") {
    var ticket_header_image = UrlFetchApp.fetch("https://drive.google.com/uc?export=view&id=" + ticket_header_image_id).getBlob();
    ticket_body.insertImage(0, ticket_header_image);
  }
  
  // ユーザ情報記入
  var ticket_txt = ticket_body.editAsText();
  ticket_txt.appendText(answers["お名前"] + " 様\n");
  if (answers["ご来場予定者数"] != "キャンセル") {
    ticket_txt.appendText("ご来場予定者数: " + answers["ご来場予定者数"] + "名");
  } else {
    ticket_txt.appendText("ご来場予定者数: 0名");
  }
  ticket_txt.setFontSize(24);
  
  // フッタ画像挿入
  if (ticket_footer_image_id != "") {
    var ticket_footer_image = UrlFetchApp.fetch("https://drive.google.com/uc?export=view&id=" + ticket_footer_image_id).getBlob();
    ticket_body.appendImage(ticket_footer_image);
  }

  // チケット管理
  if (ticket_sheet_id != "") {
    // チケット管理スプレッドシートの取得
    var sheet = SpreadsheetApp.openById(ticket_sheet_id).getSheets()[0];

    // チケット一覧を一段下げる
    var logs = 1000;
    var upper_row = sheet.getRange(2, 1, logs, 2);
    var lower_row = sheet.getRange(3, 1, logs, 2);
    lower_row.setValues(upper_row.getValues());

    // 同一edit_urlの古いチケットドキュメントを管理表から削除する
    var old_transaction = lower_row.getValues();
    for (row = 0; row < logs; row++) {
      if (old_transaction[row][0] == edit_url && old_transaction[row][1] != "") {
        DriveApp.getFolderById(ticket_folder_id).removeFile(DriveApp.getFileById(old_transaction[row][1]));
        sheet.getRange(3 + row, 2, 1, 1).setValues([[""]]);
      }
    }

    // チケットリストの最上段にデータを挿入する
    var top_row = sheet.getRange(2, 1, 1, 2);
    top_row.setValues([[edit_url, ticket_id]]);
  }

  var ticket_url = ticket_doc.getUrl();
  return {"id": ticket_id, "url": ticket_url}
}

// Gmailでメールを送る
function sendMail(answers, subject, message, admin) {
    // ユーザへの確認メール送信
    GmailApp.sendEmail(answers["メールアドレス"], subject, message);
    if (admin) {
      // 管理者への確認メール送信
      GmailApp.sendEmail(Session.getActiveUser().getEmail(), subject, message)
    }
}

// LINEでPushメッセージを送る
function pushMessage(message) {
  var push_data = {
    "to": group_id,
    "messages": [
      {
        "type": "text",
        "text": message,
      }
    ]
  };
  var options = {
    "method": "POST",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + access_token
    },
    "payload": JSON.stringify(push_data)
    "muteHttpExceptions": true
  };
  UrlFetchApp.fetch("https://api.line.me/v2/bot/message/push", options);
}

function accept(e) {
  // 回答内容取得
  // answers[各質問項目のタイトル] := 各質問項目の回答
  var items = e.response.getItemResponses();
  var answers = {};
  items.forEach(function(item) {
    answers[item.getItem().getTitle()] = item.getResponse();
  }
  // 回答編集URL取得
  var edit_url = e.response.getEditResponseUrl();
  // チケット作成
  var ticket = createTicket(answers);
  // 確認メール送信
  var subject = "受付確認"
  var mail_message = "" \
  + answers["お名前"] + "様(" + answers["ご来場予定者数"] + "名)\n" \
  + "来場予約を承りました。\n\n" \
  + "チケット:\n" \
  + ticket["url"] + "\n\n" \
  + "予約の変更は以下からお願いします。\n" \
  + edit_url
  sendMail(answers, subject. mail_message, true);
  // LINEメッセージ送信
  var push_message = answers["お名前"] + "様より" + answers["来場予定者数"] + "名の来場予約を承りました。"
  pushMessage(push_message);
}

// リマインダ
function remind() {
  if (form_id != "") {
    // フォームの回答からキャンセルではなくリマインダ「要」の人にメール送信
    var form = FormApp.openById(form_id);
    var responses = form.getResponses();
    responses.forEach(function(response) {
      var items = response.getItemResponses();
      var answers = {};
      items.forEach(function(item) {
        answers[item.getItem().getTitle()] = item.getResponse();
      }}
      if (answers["お名前"] != "" && answers["メールアドレス"] != "" && answers["ご来場予定者数"] != "キャンセル" && answers["リマインダ希望"] == "要") {
        var subject = "リマインダ";
        var message = "イベントが近づいてきました、準備は大丈夫ですか?";
        sendMail(answers, subject, message, false)
      }
    })
  }
}

システムのセキュリティについて

今回作成したシステムはユーザに対してかなりの割合で性善説を適用している。 具体的にはパフォーマンスを相当軽視して作っているので、大量に予約やキャンセルを送信する攻撃に滅法弱い。 また、対象のメールアドレスさえあればこのシステムを踏み台にして大量にシステム返信メールを送る攻撃ができる。 試してほしくはないが100人くらいで結託すればものの数分でターゲットに多大なる迷惑を被らせることができるだろう。 更に厄介なのがユーザアカウント不要=匿名で利用できるので悪意のあるユーザがいたとしてそれを追跡できない。 考えれば考えるほどガバいので、真に不特定多数の目に触れることが予想される場合は大人しくPeaTiXとかEventRegistとか使ってほしい。

Future Work

本シリーズではユーザの事前申込に対するチケット自動作成とメールおよびLINEでの通知を達成した。

試していないが、イベント当日の来場確認システムも作れるんじゃないかと考えている。

  • 指定したチケットを無効化する(画像を差し替える、チケットそのものを削除するなど)Web Appを作成する
  • Web Appへのリクエストを発行するQRコードをチケット中に埋め込む
  • 受付時にQRコードを読むことでそのチケットの来場確認(無効化)を行う

最後に

このシリーズで作成したシステムが動いている演奏会はこちらです(宣伝)

2019年11月30日(土)開場:13時00分  開演:13時30分
すみだトリフォニーホール 小ホールにて(東京都墨田区)
【アクセス】JR「錦糸町駅」北口より徒歩5分/東京メトロ「錦糸町駅」3番出口より徒歩5分
入場無料 事前予約制

おまけ:LINE Messaging APIの送信数制限について

前記事にも書いたようにLINE Messaging APIのフリープランにおけるPushメッセージ無料送信数は1,000通/月であり、 その枠はグループ宛の送信をするとグループのメンバー数に比例して消費されていく。 さらにフリープランではメッセージ枠を追加で購入することもできない。

ではメッセージのPost数を見積もってみてこの無料枠を超えそうな場合はどうすればよいか? 自明な解はライトプランやスタンダードプランへ変更して課金することである。 無料枠も増えるし、送信枠の追加購入もできる。

しかしここで貧乏性を発揮して、どうにかしてフリープランで済ませられないかを考えてみる。

この問いの解の一つとして、Botを複数台用意する、という方法を挙げる。つまり、

  • 月の初めはBot Aがグループに参加している
  • Bot Aの送信枠を使い切る、もしくは使い切りそうになったらBot Aを退出させてBot Bをグループに参加させる
  • 以下、必要に応じてBotを逐次投入していく

Botを入れ換える度にシステムに登録したアクセストークンを入れ換えるのもいいが、さらに横着して参加中のBotに応じたアクセストークンを取得する関数が以下である。 なお、挙動の説明は割愛する。

// アクセストークンを管理するスプレッドシートのオブジェクトID
// 各Botのアクセストークンを行ごとに記載しているものとする
var token_sheet_id = PropertiesService.getScriptProperties().getProperty("TOKEN_SHEET_ID") || "";
// 対象のグループID
var group_id = PropertiesService.getScriptProperties().getProperty("GROUP_ID") || "";
// グループに参加している任意のユーザのユーザID
var user_id = PropertiesService.getScriptProperties().getProperty("USER_ID") || "";

function getToken() {
  var tokens = [];
  if (token_sheet_id != "") {
    var sheet = SpreadsheetApp.openById(token_sheet_id).getSheets()[0];
    var token_num = sheet.getMaxRows();
    var data = sheet.getRange(2, 1, token_num, 1).getValues();
    data.forEach(function(d) {
      if (d[0] == "") { continue; }
      tokens.push(d[0]);
    }
  }
  tokens.forEach(function(token) {
    try {
      // グループの適当なメンバーのプロフィールを取得する。
      // グループに参加していないBotのリクエストはエラーが発生するので、エラーが起きなかった(=グループに参加している)Botのアクセストークンがreturnされる
      UrlFetchApp.fetch("https://api.line.me/v2/bot/group/" + group_id + "/member/" + user_id, {"method": "GET", "headers": {"Content-Type": "application/json", "Authorization": "Bearer " + token}});
      return token
    } catch (err) {
      continue;
    }
  })
  return ""
}