LINE Messaging APIとGASを使ってシェアハウスのごはん会ポイントを自動管理

#背景
## ごはん会とごはん会ポイント
僕の住むシェアハウスでは、不定期に**ごはん会**が開催されます。
いつも各自が自炊するハウスメイトたちが、**一緒に楽しく**美味しいごはんを作り、普段交わらない組み合わせのメンバが集まったりすると新たな化学反応が生まれたりして、楽しく交流できるとても良いイベントです。

シェアハウスの運営陣が支援してくれているのか、このごはん会を盛り上げるための**「ごはん会ポイント」**という制度が存在します。
ごはん会ポイントはある一定数のハウスメンバーが集まってごはん会を開催するとポイントが加算されます。
ポイントが貯まるとたこ焼き器やミキサー等、ごはん会をより盛り上げるための景品と交換してもらえます。

普通にお得^^

## 現状のポイントカウント方法の課題
ごはん会後に、開催したことを証明する記録用紙に誰かが記録します。
しかし、ごはん会後は後片付け/食器洗いをしているメンバーと、片付け組からはみ出たリビングでくつろいでいるメンバーの大きく2組に別れ、後者は基本**スマホを眺めている**ことが多いです。

これが重なると、ごはん会を開催しているのにポイントが加算されずに放置され、貯まるはずのポイントが貯まらないので損をしてしまいます。

じゃあ、そもそも紙で書いて証明する必要がなさそうだし、
**LINEにbot仕込んでそいつにポイント管理させればスマホで簡単に記録できるし把握もできるんじゃね？**
と思いはじめました。

LINEで完結すれば、紙で書くめんどくささもなくなるし、
>片付け組からはみ出たリビングでくつろいでいるメンバー

記録するときはこの人たちにお願いすればよさそうだなとか思ったりしました笑

# 出来上がったbot
## 使い方
**「ご飯会したよ」**で現在のポイントから開催分のポイントを加算してくれます
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/36470940-77c8-f5fc-d369-1e8793629895.png' alt='報告' width=500px>

不正防止で、 **同日に「ご飯会したよ」って言うとBotに注意**されます。
基本的にごはん会が同日に開催されることってないのでこの仕様にしています。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/406207de-d1fa-ed66-de03-4a30b89993d2.png' alt='報告' width=500px>


**「ポイント教えて」**で現在のポイントを確認できます。
ポイントで何と交換する〜？って話し合うときにこれで外出しているときも使えますね。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/2375df43-a1b8-d890-ed02-48c435d3f605.png' alt='ポイント確認' width=500px>


**「ポイント編集 [適当な数値]」**でポイントを更新します。
ただし、権限付きで、LINEグループでは僕だけがこの機能を使えます。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/54fb1010-84b2-736a-dfc4-125a5824d2c6.png' alt='ヘルプ' width=500px>


**ヘルプ**で使い方を教えてくれるような機能も用意してみました。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/aeacce34-aa36-67f2-b31d-c9f5ecbca9b2.png' alt='ヘルプ' width=500px>

## 仕組み
使ったサービスは

- LINE Messaging API
- GAS(Google Action Script)
- Google Sheets（スプレッドシート）

LINEでのユーザの発言をもとにGASでリプライの処理をさせます。
スプレッドシートで、ごはん会の開催日時とポイントを管理します。

<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/89c4cb1e-f5e5-8ea4-cea6-d042b22f4242.jpeg' alt='システム図' width=600px>


# 下準備
GASに処理を書いていく前に、必要な物を準備しましょう。
LINEのBotのアカウント作成とスプレッドシートの作成が必要になるのでそれをやっていきます。

LINE Botの作成にはLINE Developersに登録している必要がありますが、そこは省略させていただきます。

## LINE Botのアカウント作成
LINE Developersの登録が完了したらLINE Developersコンソールを開いてチャネルを作成します。
必要な情報を入力して作成します。
**チャネルの種類**という項目は`Messaging API`にして、他の必須項目は自分で任意の情報を入力してください。

チャネルを作成したら設定をしていきます。
Messaing API設定の項目に行くとQRコードが表示されるので自分の端末に読み込んで友達登録しておきましょう。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/a6f931ce-4a2f-3aed-8ef5-db712d31a1df.png' alt='シート' width=300px>

LINE公式アカウント機能の項目は、今回はシェアハウスのLINEグループに導入するので以下で設定します。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/58f49df7-6ac0-43c7-c3db-6e5a543f5a52.png' alt='システム図' width=450px>

そのすぐ下にあるチャネルアクセストークンの発行ボタンを押して、トークンを発行しましょう。
このトークンはGASで実装する際に使うのでコピーしておきましょう。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/bbd517d9-1b8a-195d-40ca-c8502ffb7bed.png' alt='システム図' width=250px>

これでBotアカウントの準備は完了です。


## スプレッドシートの準備
Googleドライブで任意の場所でスプレッドシートを作成します。
スプレッドシートにはこれだけしか用意していません。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/7af4ca34-b56b-bd6b-3e9f-8773f6bcf86c.png' alt='シート' width=400px>

ついでにGASのエディタも開いてしまいましょう
「ツール」 > 「スクリプト エディタ」で開きます。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/f485573b-46ca-a89b-5faa-21b368ab8f07.png' alt='シート' width=400px>

#実装

ポイントの更新・確認・編集をやっていきます。
ヘルプ機能は、言ってしまえば文字列を返すだけなので省略させてもらいます。

## ひとまずBotにおうむ返しさせてみる

下記のコードで実現できます。
`CHANNEL_ACCESS_TOKEN`は先ほどLINE Developersのチャネル設定で発行したトークンのことです。

```javascript
// Messaging APIのチャネルアクセストークン
const CHANNEL_ACCESS_TOKEN = '作成したトークン'
// 応答メッセージ用のAPI URL
const url = 'https://api.line.me/v2/bot/message/reply';

function doPost(e) {
  // WebHookで受信した情報
  const event = JSON.parse(e.postData.contents).events[0];
  // WebHookで受信した応答用Token
  const replyToken = event.replyToken;
  // ユーザからのメッセージ
  const userMessage = event.message.text;

  UrlFetchApp.fetch(url, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN,
    },
    'method': 'post',
    'payload': JSON.stringify({
      'replyToken': replyToken,
      'messages': [{
        'type': 'text',
        'text': userMessage,
      }],
    }),
  });

  return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
}
```

### デプロイと確認
コードを書き終わったらエディタを開いている画面右上に青色のデプロイボタンがあるのでそれを押します。

このようなビューが表示されるので、「種類の選択」から**ウェブアプリ**を選択します。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/16384989-d26a-926e-b8f2-384b14765855.png' alt='シート' width=600px>

**説明**にはどういうデプロイなのかを説明する文を入れます。（入れなくても良いですが、元に戻したい時にたくさんのデプロイバージョンがあると戻したいバージョンがわからなくなるので入れておいた方が良さげ）
**アクセスできるユーザ**は**全員**を選択します。
最後にデプロイを押します。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/0d707219-4151-8784-4285-94102ac45d61.png' alt='シート' width=600px>

デプロイが更新されると下記のビューが表示されるので、**ウェブアプリのURL**をコピーします
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/dfab62fc-14cf-1e1f-b12d-e5792b807d6a.png' alt='シート' width=600px>

LINE Officail Account Managerの自分の作成したBotアカウントの設定ページに行き、Messaging APIの**Webhook URL**欄にコピーしたURLをペーストして、保存します。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/c7e042ae-a1a0-ad34-3d7f-4eca4203c556.png' alt='シート' width=600px>

保存が完了したらLINEを開いて適当に文字をBotに送ってみましょう。
おうむ返しされるようになります。
<img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/550560/5d5e1bb1-e9ee-209f-e1ec-b4151c5a7dee.png' alt='シート' width=400px>
これ以降、デプロイするときは毎回この流れを取ってもらえればOKです。

## ポイント確認をする
ユーザからのメッセージが`ポイント教えて`だったらポイントを確認するような処理を書いていきます。
用意したスプレッドシートの**B2**にごはん会ポイントが記録されているのでその情報を取得するようにします。

`SpreadsheetApp.openById()`の括弧内の`スプレッドシートID`は用意したスプレッドシートの、
`https://docs.google.com/spreadsheets/d/XXXXXX/edit#gid=0`とあった場合の`XXXXXX`部分です。

また、`spreadSheet.getSheetByName()`の括弧内の`シート名`は、文字通りスプレッドシートのシート名を指定してあげればOKです。
デフォルトだと`sheet1`みたいになっている部分ですね。

```javascript
// スプレッドシート
const spreadSheet = SpreadsheetApp.openById('スプレッドシートID');

function doPost(e) {
  // 省略
  const reply = replyMessage(userMessage);

  // 特定の文言ではない限りリプライは送らない
  if (reply) {
    UrlFetchApp.fetch(url, {
      // 省略
        'messages': [{
          'type': 'text',
          'text': reply, //userMessageだったけどreplyに変更
        }],
      // 省略
    });
  }
}

function replyMessage(message) {
  const sheet = spreadSheet.getSheetByName('シート名');
  const point = sheet.getRange('B2').getValue();

  if (message == 'ポイント教えて') {
     return '今のご飯会ポイントは\n' + point + 'pt です';
  }
}
```

## ポイント更新
`ごはん会したよ`と報告が来たらポイントを更新するような処理を書いていきます。
今回のコードでは、報告を受けたら+300ptするようにしています。

処理は確認とほとんど変わりません。
スプレッドシートをgetしていましたが、setしてあげれば良いです。
説明の簡略化のため、確認時にも使った`replyMessage`の中身を書き換えます。

```javascript
function replyMessage(message) {
  const sheet = spreadSheet.getSheetByName('シート名');
  const point = sheet.getRange('B2').getValue();

  if (message == 'ごはん会したよ') {
    const newPoint = point + 300;
    //スプレッドシートのデータを更新しておく
    sheet.getRange('B2').setValue(newPoint);

    return 'ご飯会ポイントは' + newPoint + 'ptになりました';
  }
}
```

### 不正防止
同じ日に`ごはん会したよ`と来たらポイントが連続で増え続けてしまいますのでそれを防ぎましょう。
最終更新の日付の情報はスプレッドシートのC2に記録していたので、そこを参照します。

スプレッドシートから取得した日付と`Date()`で取得した現在の日付のデータがフォーマットが統一されておらず、そのままだと日付が同じかどうかが比較できないので、フォーマットを合わせる必要があります。

`Moment.js`という日付操作系ライブラリもあるのでそっちのが楽かなと思いましたが、今回はライブラリ使わない縛りでやっていたのでこのような形になりました。

```javascript
function updateMessage(sheet, point) {
  const date = sheet.getRange('C2').getValue();
  const today = new Date();
  const formattedDate= Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM/dd');
  const formattedToday= Utilities.formatDate(today, 'Asia/Tokyo', 'yyyy/MM/dd');
  const shouldUpdate = (formattedToday != formattedDate);

  // すでに同日に更新がされている場合は更新を行わない
  if (shouldUpdate) {
    // 更新する処理をして、文言を返す
  } else {
    // 更新できない旨の文言を返す
  }
}
```

## ポイント編集（一部ユーザのみ）
`ポイント編集 [任意の数値]`で権限のあるユーザだけポイントをいじれるような処理を書きます。

まずはコード内の(1)でwebhookで受信した情報からユーザのIDを取得しておきます。
(2)で(1)で取得したユーザIDと、もともと定めていた権限のあるユーザのIDと一致するかを確認します。

このときちょっと癖があるのが、文字列の**部分一致**になるところです。
そこで`match()`を使用しました。

(3)では変更するポイントの数値を取りたいので、**半角スペース**を区切りとみなし数値部分だけを取得し、その下の`isFinite()`で数値かどうかを判定するようにしました。

```javascript
// ユーザIDを格納するための変数
let userId;

function doPost(e) {
  // WebHookで受信した情報
  const event = JSON.parse(e.postData.contents).events[0];
  // 省略

  userId = event.source.userId; //(1)

  //　省略
}

function replyMessage(message) {
  const sheet = spreadSheet.getSheetByName('ご飯会pt');
  const point = sheet.getRange('B2').getValue();
  
  // 編集権限ユーザ、かつ編集するための文字列かを判定
  if (message.match('ポイント編集') && (userId == '編集しても良いユーザのID')) { //(2)
    const newPoint = Number(message.split(' ')[1]); //(3)

    // 数値か無効な値かを判定
    if (isFinite(newPoint)) {
      sheet.getRange('B2').setValue(newPoint);
      return 'ご飯会ポイントが' + point + 'ptから' + newPoint + 'ptに更新されました';
    } else {
      return '無効な値が入力されています'
    }
  }
}
```

## デバッグ
下記の記事をそのまま参考にしてもらえると良いかと思います。
スプレッドシートにデバッグしたい情報を出力するようにしているので大変役立ちました。

https://qiita.com/Ko001/items/f72f7dc59116d961581e

## コード全体
まだ汚いのですが💦、すこしだけまとめました。

<details><summary>長いので折りたたみました。▶︎を押すと開きます</summary><div>

```javascript
// Messaging APIのチャネルアクセストークン
const CHANNEL_ACCESS_TOKEN = "取得したトークン";
// 応答メッセージ用のAPI URL
const url = 'https://api.line.me/v2/bot/message/reply';
// スプレッドシート
const spreadSheet = SpreadsheetApp.openById('スプレッドシートID');
// 権限持つ人のID
const Author_ID = '権限持たせたいユーザID';
// ユーザID
let userId;

// ユーザから来るメッセージの種類
const UserMessageType = {
  check : 'ポイント教えて',
  report : 'ご飯会したよ',
  edit : 'ポイント編集 ', 
  help : 'ヘルプ'
}

// 受信した情報をもとにリプライを送信
function doPost(e) {
  // WebHookで受信した情報
  const event = JSON.parse(e.postData.contents).events[0];
  const replyToken = event.replyToken;
  const userMessage = event.message.text;

  userId = event.source.userId;

  const reply = replyMessage(userMessage);

  // 特定の文言ではない限りリプライは送らない
  if (reply) {
    UrlFetchApp.fetch(url, {
      'headers': {
        'Content-Type': 'application/json; charset=UTF-8',
        'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN,
      },
      'method': 'post',
      'payload': JSON.stringify({
        'replyToken': replyToken,
        'messages': [{
          'type': 'text',
          'text': reply,
        }],
      }),
    });

    return ContentService.createTextOutput(JSON.stringify({'content': 'post ok'})).setMimeType(ContentService.MimeType.JSON);
  }
}

// リプライメッセージの作成
function replyMessage(message) {
  const sheet = spreadSheet.getSheetByName('シート名');
  const point = sheet.getRange('B2').getValue();

  switch (message) {
    case UserMessageType.check:
      return '今のご飯会ポイントは\n' + point + 'pt です';

    case UserMessageType.report:
      return updateMessage(sheet, point);

    case UserMessageType.help:
      return '適当な文言';

    default:
      return editMessage(sheet, message, point);
  }
}

// ご飯会報告時のリプライ文言を作成
function updateMessage(sheet, point) {
  const date = sheet.getRange('C2').getValue();
  const today = new Date();
  const formattedDate= Utilities.formatDate(date, 'Asia/Tokyo', 'yyyy/MM/dd');
  const formattedToday= Utilities.formatDate(today, 'Asia/Tokyo', 'yyyy/MM/dd');
  const shouldUpdate = (formattedToday != formattedDate);

  // すでに同日に更新がされている場合は更新を行わない
  if (shouldUpdate) {
    const newPoint = point + 300;
    //スプレッドシートのデータを更新しておく
    sheet.getRange('B2').setValue(point);
    sheet.getRange('C2').setValue(date);

    return 'ご飯会ポイントは' + newPoint + 'ptになりました';
  } else {
    return 'もうご飯会はやったことを伝えるメッセージ';
  }
}

// 編集時のリプライ文言を作成
function editMessage(sheet, message, point) {
  // 編集権限を持っており、かつ編集するための文字列かを判定
  if (message.match(UserMessageType.edit) && (userId == Author_ID)) {
    const newPoint = Number(message.split(' ')[1]);

    // 数値か無効な値かを判定
    if (isFinite(newPoint)) {
      sheet.getRange('B2').setValue(newPoint);
      return 'ご飯会ポイントが' + point + 'ptから' + newPoint + 'ptに更新されました';
    } else {
      return '無効な値が入力されています'
    }
  }
}
```
</div> </details>

# おわりに
動かすだけなら2時間くらい、慣れないgasのコーディングで汚くなったソースコードを綺麗にしたりグループに導入したりするのにちょっと詰まったりしたものの、合計で**約6時間くらい**で完成しました。（コードがまだ綺麗になりそうな気がしているので、アドバイスがある方はコメントをいただけると幸いです🙇‍♂️）

個人利用くらいの規模感で使うならMessaging APIを無料で使えるので、お金もかけずに生活の一部を自動管理して便利にできそうですね。

# 参照
1. [GASで簡単にLINE botを作成！ – GASで簡単にLINE botを作成！ – API基本設定からメッセージ取得・送信方法まで解説！](https://jorublog.site/line-messaging-api-gas-setup/)
1. [Google Apps ScriptでLINE BOTつくったら30分で動かせた件](https://qiita.com/hakshu/items/55c2584cf82718f47464)
1. [【Google Apps Script入門】セルの取得・変更をする | UX MILK](https://uxmilk.jp/25841)
1. [Google Apps Script(GAS)の日付を文字列に変換する方法](https://auto-worker.com/blog/?p=1114)
1. [GAS × Line Messaging Api のデバッグ](https://qiita.com/Ko001/items/f72f7dc59116d961581e)