JavaScript
GoogleAppsScript
gas
Slack

Slack+Google Apps Script (GAS)で研究室内電子マネー(もどき)を作った

はじめに

私の所属する研究室では定期的に飲み物やお菓子,カップ麺などをネットスーパーで買い溜めしています。
研究室のメンバーは金庫にお金を払うことで好きな時にこれらを購入できるようになっています。

私もしょっちゅう利用するのですが,財布から小銭を探すのが面倒だし,金庫が小銭で溢れ返る・・・

解決策

予めチャージして使った金額を記録すれば楽なのでは?
ということでSlackを通してチャージと購入を記録できるGASを書きました。
購入時も楽だしチャージも時々大きい単位でするだけなので金庫も溢れません。

作ったもの

Slackに研究室内購買専用のチャンネルを作成し,チャンネル内で特定のコマンドをコメントすることでbotが動作します。
以下の4つの機能を実装しました。

チャージ

チャージ

チャンネル内で!st c 1000のようにチャージ金額をコメントするとbotがチャージを記録してくれます。(画像はテスト時のものなので0円です)
ユーザーはこの時に金庫にチャージ額分のお金を入れます。
詳細は後述しますがチャージした人のユーザー名や金額などの情報がスプレッドシートに記録されます。
!stをbotの動作するトリガーにしてあります。

購入

購入
欲しい商品の値段を値段表(部屋に貼ってある)で確認し,金額を!st b 100のようにコメントします。
同様にbotが記録をつけてくれます。
残高がマイナスになるとチャージを促すメッセージも出るようになります。
個別に通知メッセージも送信します。(購入は可能です)

残高確認

zandaka.PNG
!stとだけコメントすることでbotが残高を教えてくれます。

値段検索

商品の種類が増えてくると値段表から探すのが面倒になるので,商品名の一部をクエリにして検索できるようにしました。
値段検索

!st f 検索クエリとコメントすると値段表から該当する商品を検索し,ユーザーに個別に結果を通知します。
検索結果
結果がユニークに定まる場合は自動で購入の処理も行うようにしました。

ひらがなとカタカナ,アルファベットの大文字と小文字は区別しないようにしているので商品名と厳密に一致しなくても検索できるようになっています。

方法

GAS

プロジェクトの作成

Googleドライブ
Googleドライブにアクセスし,
「新規」→「その他」→「Google Apps Script」を選択します。
Google Apps Scriptがない場合は「アプリの追加」を選択し,Google Apps Scriptを検索して「接続」を押せば追加されます。

スクリプトを書く

作成したプロジェクトを開き,スクリプトを書きます。
拙いですが,以下のコードを書きました。

/*
String.trim()を使えるようにする
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/trim#Compatibility より引用
*/
if(!String.prototype.trim) {
  String.prototype.trim = function () {
    return this.replace(/^\s+|\s+$/g,'');
  };
}

function doPost(e) {
  var verify_token = PropertiesService.getScriptProperties().getProperty("VERIFY_TOKEN");
  if (verify_token != e.parameter.token) {
    throw new Error("Invalid token.");
  }


  var commands = e.parameter.text.replace(/\s+/g, " ");//連続する空白文字を1個に直す
  commands = commands.trim().split(" ");//前後の空白を取り除いてから切り分ける

  if (commands.length == 1){//残高確認
    var z = get_zandaka(e.parameter.user_name);
    return post_message(e.parameter.channel_id, "<@"+e.parameter.user_id+">の残高: "+z+"円");
  }else if (commands.length != 3){
    throw new Error("Invalid syntax.");
  }

  if (commands[1] == "f"){//検索
    var result = search_item(commands[2]);
    var m = "";
    for (var i=0; i<result.length; i++){
      m += result[i][1]+":"+result[i][2]+"円\n";
    }

    if (result.length == 0){
      return post_message(e.parameter.user_id, "「"+commands[2]+"」に該当する商品が見つかりません。");
    }

    if (result.length == 1){//結果が一意に定まるときは購入もする
      transaction(e, "b", parseInt(result[0][2]));
      m = "*結果が一件だったので購入しました。*\n" + m;
    }

    return post_message(e.parameter.user_id, m);//検索結果をユーザーに個別に返す
  }else{//購入 or チャージ
    var money = parseInt(commands[2]);
    if (isNaN(money)){
      throw new Error("Invalid value.");
    }
    return transaction(e, commands[1], money);
  }
}

/*
購入またはチャージの処理
ac: "b" または "c"
money: 金額
*/
function transaction(e, ac, money){
  var zandaka = record(e.parameter.timestamp, e.parameter.user_name, ac, money);
  var action;
  switch(ac){
    case "b":
      action = "購入";
      break;

    case "c":
      action = "チャージ";
      break;

    default:
      throw new Error("Invalid action.");
  }

  var message = "<@"+e.parameter.user_id+">が"+action+"しました。\n"+action+"金額: "+money+"円\n残高: "+zandaka+"円";
  if (zandaka < 0){
    message += "\n*チャージしてください*:yen:";
    var user_m = "残高がマイナスです。\n*チャージしてください*:yen:";
    post_message(e.parameter.user_id, user_m);//残高がマイナスのときは個別に通知
  }
  if (ac == "c"){
    message += "\n*金庫にお金を入れてください。*";
  }

  return post_message(e.parameter.channel_id, message);
}

/*
スプレッドシートに書き込んで残高を返す
*/
function record(timestamp, username, action, money){
  var sheet = get_sheet("LOG_SHEET");

  // 最終行から走査して直近のusernameの履歴を見つける
  var row,r;
  var lr = sheet.getLastRow();
  var lc = sheet.getLastColumn();
  for(r=lr; r>=2; r--){
    row = sheet.getSheetValues(r, 1, 1, lc)[0];
    if(row[1] == username){
      break;
    }
  }

  var zandaka, a;
  if (r == 1){
    //見つからなかった(はじめての利用)
    zandaka = 0;
  }else{
    zandaka = parseInt(row[4]);
  }

  if (action == "b"){
    zandaka -= money;
    a = "buy";
  }else if (action == "c"){
    zandaka += money;
    a = "charge";
  }else{
    throw new Error("Invalid action.");
  }

  if (money != 0){//0円のときは書き込まない
    sheet.getRange(lr+1, 1, 1, lc).setValues([[timestamp, username, a, money, zandaka]]);
  }

  return zandaka;
}

/*
残高取得
*/
function get_zandaka(username){
  var sheet = get_sheet("LOG_SHEET");

  // 最終行から走査して直近のusernameの履歴を見つける
  var row, r;
  var lr = sheet.getLastRow();
  var lc = sheet.getLastColumn();
  for(r=lr; r>=2; r--){
    row = sheet.getSheetValues(r, 1, 1, lc)[0];
    if(row[1] == username){
      break;
    }
  }

  if (r == 1){//usernameがない場合はまだ使用したことがないので0円
    return 0;
  }else{
    return row[4];
  }
}

/*
商品検索
*/
function search_item(query){
  var sheet = get_sheet("PRICE_SHEET");

  var all_items = sheet.getRange(2, 1, sheet.getLastRow()-1, 3).getValues();
  var result = [];
  var kensaku;
  for(var i=0; i<all_items.length; i++){
    //カタカナ・小文字に統一してから検索
    kensaku = hiraganaToKatakana(""+all_items[i][0]+all_items[i][1]).toLowerCase().replace(/\s/g, "");
    if (kensaku.match(hiraganaToKatakana(query).toLowerCase())){
      result.push(all_items[i]);
    }
  }

  return result;
}

/*
ひらがなをカタカナに変換する
https://qiita.com/mimoe/items/855c112625d39b066c9a より引用
*/
function hiraganaToKatakana(src) {
    return src.replace(/[\u3041-\u3096]/g, function(match) {
        var chr = match.charCodeAt(0) + 0x60;
        return String.fromCharCode(chr);
    });
}

function get_sheet(sheet_name){
  var ss = SpreadsheetApp.openByUrl(PropertiesService.getScriptProperties().getProperty(sheet_name));
  return ss.getSheets()[0];
}

/*
Slackにメッセージを投稿
*/
function post_message(channel_id, message){
  var app = SlackApp.create(PropertiesService.getScriptProperties().getProperty("SLACK_ACCESS_TOKEN"));
  return app.postMessage(channel_id, message, {
    //icon_url: "アイコンのURL",
    username: "Store"    /*botの名前*/
  });
}

ライブラリの追加

GASからSlackにメッセージを送信するためにこちらのライブラリを使用させていただきました。

メニューの「リソース」→「ライブラリ」を選択し,「ライブラリを検索」の欄にライブラリキーM3W5Ut3Q39AaIwLquryEPMwV62A3znfOOを入力します。
SlackAppというライブラリが追加されるはずなので,「バージョン」で最新のものを選択して「保存」を押します。

スプレッドシート

ログ記録用

購入やチャージ,残高などの情報を記録するためのスプレッドシートを作成します。
同様にして「新規」→「Googleスプレッドシート」を選択して作成します。
画像のようにカラムを5列作ります。

ログ

入力するのは青で表した1行目のみです。
2行目以降はユーザーが利用するたびに自動的に入力されていきます。

次に,GASからスプレッドシートにアクセスできるようにします。
作成したスプレッドシートのURLをコピーし,GASのプロジェクトを開きます。
メニューの「ファイル」→「プロジェクトのプロパティ」を表示し,「スクリプトのプロパティ」タブを開きます。
「行の追加」をクリックし,プロパティ名にLOG_SHEET,値に先程コピーしたURLを貼り付けて保存します。

値段表

値段表も同様にスプレッドシートで作成します。
price_sheet.PNG
画像のように3列の値段表を作ります。
品番は任意ですが入力しておくと検索に使えます。
印刷して見える位置に貼っておくと良いと思います。

先程と同様にシートのURLをコピーしてメニューの「ファイル」→「プロジェクトのプロパティ」を表示し,「スクリプトのプロパティ」タブを開きます。
プロパティ名PRICE_SHEET,値にURLを貼り付けて保存します。

Slack

Slack APIの作成

GASからSlackに投稿させるためのアプリを作成します。
https://api.slack.com/apps にアクセスし,Create New Appをクリックします。
アプリ名を入力し,使用するワークスペースを選択してCreate Appします。

以下のような画面になるはずなので,アプリの基本設定を行います。
アプリ作成画面

中央のメニューの「Permissions」をクリックし,少し下にスクロールしてScopesに移動します。
ここでアプリに許可する権限を設定します。
Slackに投稿するだけで良いので,Select Permission Scopesのプルダウンメニューから「Send messages as (アプリ名)」を追加してSave Changesをクリックします。

APIトークンの取得

左のメニューからBasic Informationに移動します。
ここまで正しくできていれば以下のような画面になっているはずです。
アプリのインストール
中央のInstall your app to your workspaceを開き,緑のボタンをクリックします。
Slackからアプリの連携を許可するかどうか聞かれるので許可します。

インストールできたら左のメニューからOAuth & Permissionsに移動します。
アクセストークンが表示されているはずなのでコピーします。
アクセストークン

コピーしたら再びGASのプロジェクトを開きます。
スプレッドシートのときと同様にスクリプトのプロパティを開き,プロパティ名SLACK_ACCESS_TOKEN,値にアクセストークンを貼り付けて保存します。

Outgoing Webhookの設定

Slackでコマンドを書き込んだらGAS側に送信するための仕組みを作ります。
まずはSlackで購買用チャンネルを作成します。

チャンネル設定から「アプリを追加する」をクリックし,検索欄に「outgoing webhook」と入力します。
「発信Webフック」というインテグレーションが出てくるのでインストールします。
インストールしたら「設定を追加」→「発信Webフック インテグレーションの追加」を選択します。

設定画面になるので下にスクロールし,入力欄を埋めていきます。
「チャンネル」は先ほど作成したチャンネル名を選択します。
「引き金となる言葉」にbotが作動するトリガーを設定します。ここでは「!st」とします。

URL欄は一度飛ばして,「トークン」の欄に表示されている内容をコピーします。

ここでGASのプロジェクトを開きます。
スクリプトのプロパティを開き,プロパティ名をVERIFY_TOKENとして値にトークンを貼り付けて保存します。

プロジェクトを保存し,「公開」→「ウェブアプリケーションとして導入」を選択します。
公開
画像のように設定して「導入」を押します。
Slackから権限について聞かれた場合は許可します。

正常に導入できるとウェブアプリケーションのURLが表示されるのでコピーします。
発信Webフックの設定画面に戻り,先程飛ばしたURL欄に貼り付けます。

必要ならば説明,名前,アイコンの欄も入力して設定を保存します。

動作確認

ここまですべて正しくできていればbotが完成しているはずです。
Slackからいくつかコマンドを実行して正常に動作しているか確認してください。

もっと手軽に

Slackを開いてコマンドを入力するのも面倒なので,Microsoft Flowを利用してスマホからぱぱっと購入できるようにしました。(これは各ユーザーが自分で行う必要があります)

以下はiPhoneでの説明です。
お使いのスマホにMicrosoft Flowのアプリをインストールします。
アプリを起動し,「フロー」タブを開いて右上の+を押し,「一から作成」を選びます。

フローの作成画面になるので「モバイルのFlowボタン」を選びます。
最初のステップが追加されるので「入力の追加」を押し,「Number」を選択します。
入力欄の名前は「金額」と設定します。

次に,「新しいステップ」→「アクションの追加」を選びます。
Slackを検索して「投稿メッセージ」を追加し,SlackにFlowとの連携を許可します。

投稿先チャンネルを選択してから,メッセージテキストに購入コマンドを指定します。
!st bまで入力してから下の方にスクロールすると先ほど作成した「金額」があるのでタップすると入力欄に追加されます。
メッセージテキストはこれで完成したので右上の完了をタップします。

詳細オプションを開き,「ユーザーとして投稿する」を「はい」にします。
これを忘れると自分の名前で金額が管理されません。

以下のようなフローが完成したら「作成」をタップします。
フロー

その後「ボタン」タブを開くとボタンができています。
button.png

ボタンを押して金額を入力し,確定するだけで購入処理ができるので便利です。
Androidの場合はホーム画面に直接ボタンを配置することもできるらしいです。

値段検索やチャージボタンも同様にして作ってみると良いでしょう。

おわりに

簡単なチャージ金額管理botを作成しました。
まだまだ改善の余地はあると思います。

参考