GAS(Google Apps Script)を使って精算Botを作ってみた話

# はじめに
本記事は[NTTテクノクロス Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ntt-tx)のシリーズ1 10日目の記事です。
こんにちは。[NTTテクノクロス](https://www.ntt-tx.co.jp/)の関口です。
今回の記事では、業務外での活動(= ~~遊んでみた~~ 作ってみた)ことについて記事にしていこうと思います。

:::note warn
この記事には試行錯誤の過程が含まれています。
こうするといいよ、などあたたかいコメントをいただけると幸いです。
:::

## やってみたこと
我が家では建て替たお金を1円単位できっちりと精算する文化なのですが、件数が多いとなかなか面倒....
チャットもところどころに精算用のメッセージが挟まり、追うのも大変だったので、使って精算用のBotを作ってみました。
(駆け出しエンジニアに向けて)試行錯誤する過程も含めて紹介できればと思います。
![LineBot導入ビフォーアフター.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/dac35417-643e-7e67-b1b1-51abfa3024f5.png)


# 動作環境
### Google Apps Script (GAS)
サーバレスで動かしたかったのでGoogle Apps Script (以降GAS)を使用しています。ほぼJavaScriptの感覚で書けます。

:::note
GASは無料で使用することができますが、**一部機能の回数に制限**があります。
制限を超えた後は、その機能が使用できなくなる~~らしい~~。
Fetch関数は20,000件/日使用可能　≒　20,000通/日メッセージを送れるらしい。
~~個人で使ってたらそんなに送らないよ~~　※他のGASプロジェクトとの合計

その他の制約は[こちら](https://developers.google.com/apps-script/guides/services/quotas?hl=ja)
:::
### LINE Messaging API
また、チャットアプリのbotに、LINE Messaging APIを使用しています。
いわゆるLINE公式アカウント(LINEのBot)としてメッセージのやり取りができるサービスです。

:::note
LINE Messaging APIには無料枠があります。
Botから無料でメッセージを送ることができるメッセージ数は200通/月ですが、
**返信は何度でも無料です。**
今回作るBotは返信を使用しているので、**実質無限**です。

有料メッセージの料金は[こちら](https://www.lycbiz.com/jp/service/line-official-account/plan/)
:::
~~機材や環境などを自分で準備しないでいいので、**すごく楽**です。
便利なものはありがたく使わせていただきます。~~


# まずはオウム返しできるBotを作る
まずは動いているところが見えるとワクワクしますよね。
と言うことで、LINEとつながる部分を作ってみましょう。

:::note 
下記準備できていることを前提に話を進めていきます。
* LINE公式アカウントの作成、応答メッセージをオフにする設定が完了していること
([手順はこちら](https://developers.line.biz/ja/docs/messaging-api/getting-started/))
* 長期のチャネルアクセストークンをすでに発行済であること [^1]
([発行方法はこちら](https://developers.line.biz/ja/docs/basics/channel-access-token/#long-lived-channel-access-token))
<font color="Red">※アクセストークンは外に漏れないよう、管理に気をつけましょう。</font>
:::
~~全部作って動かしてみて動かなかったら悲しいので、~~ まずはオウム返しをするようBotのプログラムを作成します。

[^1]: 推奨されているチャネルアクセストークンは、「任意の有効期間を指定できるチャネルアクセストークン（チャネルアクセストークンv2.1）」ですが、今回は1度発行すると値が変わらない「長期のチャネルアクセストークン」を使用しています。推奨されているものは動的にトークンを生成するコードを別途書く必要があり、**難易度が跳ね上がる**ため、今回は固定の長期のチャネルアクセストークンを使用しています。~~認証・検証周りは難しい。。。~~

## プロジェクトの準備
### プロジェクトの作成
Googleにログインした状態で、[GAS](https://script.google.com/home)を開くと、自分のプロジェクト一覧が出てきます。
「新しいプロジェクト」から新しいプロジェクトを作成します。

### スクリプトプロパティの設定と呼び出し
プロジェクトができたら、チャネルトークンを安全な場所に保管しておきましょう。
左下の歯車アイコン、**プロジェクト設定**をクリックし、**スクリプトプロパティ**の項目を探します。
スクリプトプロパティに下記のように値を入れ、**スクリプトプロパティを保存**を押下します。
| プロパティ | 値 |
|:-:|:-:|
| token | 【準備の際に発行済みの長期チャネルアクセストークン】 |

![プロジェクト設定導線.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/c9021305-cbf5-09bd-8819-88e1594c1194.png)
![プロパティにトークンを設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/83537dab-6026-8cfc-4ad6-83ca6f1f21b9.png)

保存できたら、左側<>アイコンの**エディタ**をクリックし、コードを書く画面に戻ります。
保存できているかを確認するために、一度コンソール上に設定した値を出してみましょう。
新規作成した後のプロジェクトには空の関数、myFunction()があるので、そこにtokenを出すコードを追記してみます。
```javascript:コード.gs
function myFunction() {
  let token = PropertiesService.getScriptProperties().getProperty("token");
  console.log(token);
}
```
コードが書けたら、実行してみましょう。
一度Ctrl+S(Macの場合はcommand+S)でプロジェクトを保存し、実行ボタンを押します。
![tokenLogに.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/fc1cb76d-9e5a-83ff-c615-1ff2dbf3382d.png)

実行ログの中にtokenとして設定した値が出てきました。

## オウム返しをする機構を作ってみる
メッセージが来ると、Webhookを通じて、メッセージ内容を含むJSONが送られてきます。
送られてくるJSONに入っている情報は[Messaging APIリファレンス](https://developers.line.biz/ja/reference/messaging-api/)にわかりやすく書かれています。また、Webhookの設定は[後ほど](#仕上げの連携作業)解説をします。
今回取り出したいデータは、「誰が」「どんな内容の」メッセージを送ってきたか？なので、それらを取り出す準備をしていきます。(あとは返信をするための応答トークンも。)
※今回はテキストメッセージにのみ反応させます。

GASでは、PostでURLが叩かれた時にdoPost関数が呼ばれます。
新しくdoPost関数を作ってみましょう。

```javascript:コード.gs
function doPost(e){
    //送られてきたJSONをオブジェクトに変換する
    var contentlist = JSON.parse(e.postData.contents);
    //eventは複数送られてくることがあるので各イベントごとに処理を実施
    for (event of contentlist.events){
        
        if(event.type == "message"){
            //誰が
            let userId = event.source.userId;
            //どんな内容のメッセージ
            let recievedMessage = event.message.text;
            //応答トークン
            let replyToken = event.replyToken;

            //返信用の変数。今回はオウム返しなので、受信メッセージをそのまま入れる。
            var replyMessage = recievedMessage;

            //送信用のあれこれ。URLに返信用の情報を入れ込んでいる。
            let url = 'https://api.line.me/v2/bot/message/reply';
            let token = PropertiesService.getScriptProperties().getProperty("token");
            var res=UrlFetchApp.fetch(url, {
                'headers': {
                  'Content-Type': 'application/json; charset=UTF-8',
                  'Authorization': 'Bearer ' + token,
                },
                'method': 'post',
                'payload': JSON.stringify({
                    'replyToken': replyToken,
                    'messages': [{
                        'type': 'text',
                        'text': replyMessage,
                    }],
                }),
            });
        }
    }
    //無事に届いたことをレスポンスする。200番台のステータスコードが返れば正直なんでも良い。
    return ContentService.createTextOutput(
        JSON.stringify({'content': 'post ok'}))
        .setMimeType(ContentService.MimeType.JSON);
}
```
## 仕上げの連携作業
### GASのURLを発行
保存をしたら、MessagingAPIと連携してみましょう。
右上の**デプロイ**ボタンより、**新しいデプロイ**を選択し、デプロイのメニューを開きます。
メニューが出てきたら、種類の選択の右側歯車アイコンより、**ウェブアプリ**を選択します。
右側に詳細の設定が出てくるので、アクセスできるユーザを<font color="red">**全員**</font>に変更し、デプロイを押します。初めてのデプロイなので、外部と繋がるための承認が必要となるので、**アクセスを承認**してあげましょう。
![デプロイボタン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/fcc04dfc-93a9-e977-ce02-a0d5ae562bc0.png)
![デプロイ始めのフロー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/44248106-1c5b-7e60-cd0d-fe3ccac9fcb1.png)


アクセスを承認するため、アカウントを選択する画面が出てくるので、今回GASを触っているアカウントを選択します。
このアプリケーションはGoogleが認証していないから危険かもしれないよ、と怒られますが、自分自身が作っているものなので、**Advanced**から詳細画面を表示した上で、**Go to 無題のプロジェクト**(作成したプロジェクト名)を選択します。~~unsafeとか言われると安全なはず、と思っていてもドキッとする...~~
ここまできて、ようやくアプリケーションの外部アクセスを承認する画面が出現します。外とつながるために、**allow**で許可してしまいましょう。
ここまでできたら、URLの発行が完了です。ウェブアプリのURLをコピーしておきます。
![アカウント認証.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/1b159adc-1541-19f8-a54a-0487b46c301b.png)
![URLコピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/f3381e2f-a5f9-d6d2-94d0-a81c9490a7d8.png)


### LINE側にURLを設定
[LINE Developers](https://developers.line.biz/console/)よりログインし、作成済みの公式アカウントを選択することで、設定画面に入りましょう。Messaging API設定より、Webhookの設定を探します。WebhookURLに先ほどコピーしたリンクを貼り付け、更新ボタンを押します。また、合わせてWebhookの利用をONにしておきましょう。

![Webhook設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/b602e2c0-820b-8e8d-b637-c7287acee9fa.png)

## 動かしてみる
話しかけると同じ内容が返ってくるBotが完成しました。
![おうむ返し成功.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/d17d0678-0107-4310-90e1-997b2613ca4d.png)

# スプレッドシートと連携する
## スプレッドシートを作成する
おうむ返しができたので、動くものができて、満足、、、してきたところで当初の目的であるスプレッドシート連携をしていきます。
ベースとなる以下のようなスプレッドシートを作成しました。
項目を追加したら、A~C列に1行ずつ記録されていき、精算をしたらこれらを全て削除する動きをさせてみようと思います。
![スクリーンショット 2024-12-05 3.42.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/3e766d49-7dca-9366-dd0f-3b69e435d882.png)

## スプレッドシートのIDを取得する。
GASで、どのスプレッドシートを操作するかを伝えるため、スプレッドシートのIDをコピーしていきます。
スプレッドシートのIDはURLのこの部分となっています。
![スプシID.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/10461e52-d910-7bf1-613a-46091adef96e.png)
IDがコピーできたら、スクリプトプロパティにスプレッドシートのIDを格納します。

| プロパティ | 値 |
|:-:|:-:|
| token | 【準備の際に発行済みの長期チャネルアクセストークン】 |
| <font color="red">sheet |  <font color="red">【スプレッドシートのID】 |

## スプレッドシートに値を追加してみる・削除してみる
### 値の追加
一旦Botからは離れて、スプレッドシートに追記をするコードを書いてみましょう。
確認のためにメッセージを送るのは~~面倒~~大変なので、値を追加する関数を書いてみます。
ついでに動くか確かめる関数も追加します。
```javascript:コード.gs
function addRecord(detail,amount){
    let sheetId = PropertiesService.getScriptProperties().getProperty("sheet");
    //sheetIdのスプレッドシートを開く
    var spreadsheet = SpreadsheetApp.openById(sheetId);
    var sheet = spreadsheet.getActiveSheet(); 
    //最終行の位置を格納
    var lastRow = sheet.getLastRow();
    //今日の日付、明細、金額を最終行の次の行に書き込み
    sheet.getRange(lastRow + 1, 1).setValue(new Date());
    sheet.getRange(lastRow + 1, 2).setValue(detail);
    sheet.getRange(lastRow + 1, 3).setValue(amount);
    return detail+" (¥"+amount+")を追加しました。";
}

function test(){
  addRecord("牛乳","100");
}
```
コードを書いたら、保存した上で、testを実行していきたいと思います。
実行する関数を、myFunctionから、testに変更した上で、実行していきます。
初めてスプレッドシートを触るときは、権限の追加が求められますので、**権限を確認**より許可してあげます。
![実行する関数を変更.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/321b5068-ba14-eec9-79c7-446f95e0a46c.png)


![スクリーンショット 2024-12-05 4.15.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/41a9345a-c157-9614-12e6-c6ffabfd0c90.png)
#### 値の確認
スプレッドシートを見に行くと、無事、追加されていることがわかります。
![スクリーンショット 2024-12-05 4.29.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/87c858d2-8cb3-4506-3486-2a4cb4ba2237.png)

### 値の削除
先ほどと同様に削除をする関数も作ってみましょう。
```javascript:コード.gs
function clearRecord(){
    let sheetId = PropertiesService.getScriptProperties().getProperty("sheet");
    //sheetIdのスプレッドシートを開く
    var spreadsheet = SpreadsheetApp.openById(sheetId);
    var sheet = spreadsheet.getActiveSheet(); 
    //最終行の位置
    var lastRow = sheet.getLastRow();
    //データがない場合、ヘッダが消えてしまうことを防止するため、lastRowが1より大きいの時のみクリアする
    if(lastRow>1){
        var lastBalance = sheet.getRange("E1").getValue();
        var range = sheet.getRange("A2:C" + lastRow);
        range.clearContent();
        return "¥"+lastBalance+"の精算が完了しました。";
    }
    return "精算するものはありません。"
}
```
同様に保存し、clearRecordを実行します。
#### 値の確認
スプレッドシートを見に行くと、無事、値が削除されていることがわかります。
![スクリーンショット 2024-12-05 4.41.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/6e92366f-2e99-4187-43bc-06571053521a.png)


この状態でもう一度clearRecordを実行し、ヘッダが消えないかも確認していきます。
実行し、スプレッドシートを見にいきましたが、大丈夫そうですね。
![スクリーンショット 2024-12-05 4.41.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/6e92366f-2e99-4187-43bc-06571053521a.png)


### 金額を確認する機能を追加する
金額を確認し、メッセージに適した形へ変換していく機構もここで先に作っておきます。
```javascript:コード.gs
function outputRecord(){
    let sheetId = PropertiesService.getScriptProperties().getProperty("sheet");
    //sheetIdのスプレッドシートを開く
    var spreadsheet = SpreadsheetApp.openById(sheetId);
    var sheet = spreadsheet.getActiveSheet(); 
    //最終行の位置
    var lastRow = sheet.getLastRow();
        if(lastRow>1){
        // 2行目から最終行までの値を取得 
            let range = sheet.getRange("A2:C"+lastRow);
            let data = range.getValues();
            //メッセージを追記していく
            var replyMessage = "";
            for (var i = 0; i < data.length; i++) { 
              var date = Utilities.formatDate(new Date(data[i][0]), "Asia/Tokyo", "MM/dd");
              var detail = data[i][1];
              var amount = data[i][2];
              replyMessage += date + " " + detail + " (¥" + amount + ")\n";
              }
              //合計金額も含める
              replyMessage += "-------------\n合計 ¥";
              replyMessage += sheet.getRange("E1").getValue();
              return replyMessage;
        }
        return "現在精算するものはありません。";
}
```
# オウム返しBotとスプレッドシートをつなげる
## どんなメッセージを送ってもらうか
最後に、すでにできているbotと計算するスクリプトをつなげていきます。
今回は、下記の条件で動くようにし、それ以外はエラーを出すようにします。
* 文字の区切りは改行とする。明細[改行]金額の形式で送られてきたものを
スプレッドシートに追記する
* 「いくら？」と送信された場合、スプレッドシートの内容と合計金額を返信する
* 「精算済み」と送信された場合、スプレッドシートをクリアにする

## メッセージによって処理内容を分岐する
Botの要である、メッセージを振り分ける機構も新たな関数にしてしまいます
キーワードを忘れても良いように、エラー文にキーワードを書いておきます。
```javascript:コード.gs
function parseMessage(message){
  if(message == "いくら？") return outputRecord();
  if(message == "精算済み") return clearRecord();
  let sentence = message.split("\n");
  if(sentence.length == 2){
    return addRecord(sentence[0],sentence[1])
  }
  return "入力形式が誤っています。\nいくら？\n精算済み\nまたは【品目名】[改行]【値段】\nと話しかけてください。"
}
```

## doPostをアップデートする
ここまで糊付けしてきたものをBotに反映させていきます。
最初に作ったものにはなるべく手を出さない形で作ったので1行変えるだけで問題ありません。
```diff_javascript:コード.gs 
         //(省略)
        if(event.type == "message"){
            //誰が
            let userId = event.source.userId;
            //どんな内容のメッセージ
            let recievedMessage = event.message.text;
            //応答トークン
            let replyToken = event.replyToken;

-            //返信用の変数。今回はオウム返しなので、受信メッセージをそのまま入れる。
-            var replyMessage = recievedMessage;
+            //返信用の変数。受け取ったメッセージをparseMessageに投げた結果を入れる。
+            var replyMessage = parseMessage(recievedMessage);

            //送信用のおまじない。URLに返信用の情報を入れ込んでいる。
            //(省略)
```
編集が完了したら忘れずに保存をしましょう。

## デプロイ
修正できたら、botに反映させていきます。
**デプロイ**より、**デプロイを管理**を選択します。
最初にデプロイしたものが出てくるので、鉛筆アイコンの **編集**ボタンをクリックします。
バージョンを選択し、**新バージョン**を選択します。これで、最新の状態が選ばれている状態になりました。
最後に**デプロイ**を押せば、オウム返しBotは精算Botに生まれ変わっているはずです。
![デプロイ更新.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/eb748338-1a1f-071c-47ba-c9dd355eed6b.png)

## 動作確認
どうやらきちんと動いていそうです。
![Screenshot 2024-12-05 at 6.06.40.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3947396/a5014f43-4767-513f-1fea-984911363bb1.jpeg)

# おわりに
本記事では、GASを使って、精算Botを作ってみました。
手元に環境がなくても動くものが作れるって素晴らしいですね！
最後までお読みいただき、ありがとうございました。

NTTテクノクロス Advent Calendar 2024、明日は @Daha さんの2回目の記事です！ぜひご覧ください！！