GoogleAppsScriptでDBを使ったLINEbotでユーザー別のデータに配慮する。

# 1. 始めに
## 1.1 前提
うちの文化祭で非公式のクイズBotを作りたいと発注が来まして、[スクレイピング](https://qiita.com/Cyber_Hacnosuke/items/122cec35d299c4d01f10)でやってた研究と文化祭のオフィシャルアプリと、[Android AI](https://qiita.com/Cyber_Hacnosuke/items/e4c62d90b776ea3debf7)と、いろいろ仕事と学業があるなか、無理やり引き継いだのがLINEbotの制作です。具体的には言えないのですが、それを作るために必要なものをかき集めてまとめました。

## 1.2 そもそもどうゆうの？
### ①GUI
LINEです。はい。
### ②処理
サーバーで行います。サーバーを[立てれるのはいいのですけど](https://qiita.com/Cyber_Hacnosuke/items/e4c62d90b776ea3debf7)維持費がきついし、公開めんどい。
そこで出てきたのがGoogleAppsScript(GAS)です。サーバーレスでWebアプリが作れるそうです。Googleのサービス、ツール(GoogleSpreadSheetとかDriveとか)との連携がいいのが推しポイントです。また、ブラウザ上のみで開発ができるので最強です。(ちなみにJavaScript)
### ③メッセージの送信
処理系統からLINEにPOSTすればいいそうです。マルチパートと同じ感じで書き方があるそうです。(JSONらしい)
### ④DB
DBはスプレッドシート。(個人的にはExcelのほうが好き)
### ⑤ユーザー情報
特にない。でも、段階的な(クリアしたら次にいく系)クイズなので、ユーザーごとに今どこまで進んでいるか、問題文、難易度、複数の答え(半角、全角、単位など答える人によっては同じ意味だけど文字的に違うかもしれないから)をDBに入れないといけない。クリアした問題は左に```"A"```を付けます。

**<ユーザー情報の準備・生成>**
```アカウント名：User```とすると
1. 問題、答えなど基本情報のあるスプレッドシート```Master```はURLとして残し、ネットに公開します。
2. それをコピーして```User```という名前のスプレッドシートを複製します。
3. 今どの問題を解いているか```User```にアクセスします。
4. Aが付いていない行の問題を```User```から取得します。
5. 問題を出します。
6. ユーザーが答えます。
7. 正解します。
8. ```User```の正解した問題にAを付けます。

こうすることでオリジナルの```Master```ファイルは破損、編集されないので友達になればいくらでも生成します。(生成されたファイルは制作者あなたのDriveに入るので個人情報は抜き取らないで)

今回はこのスプレッドシートを削除できるようにします。

# 2. LINEアカウントの生成
1. https://account.line.biz/login にアクセスして今使っているLINEアカウントでログイン。
2. プロバイダーを生成(名前は適当)。
3. 新しいチャンネルを**Messaging API**から作ります。**この時、プランをTrialとかいうやつにしてください。**
4. 初期設定いろいろしたらおｋです。
![2019-02-20.png](https://qiita-image-store.s3.amazonaws.com/0/263233/6084e9e9-e9ca-ddc9-ddfc-77b89899cc08.png)

こんな感じ。そしたら下の```アクセストークン（ロングターム）```を再発行してもらいます。失効は0時間。これで再発行されたトークンをメモ。
次に、```Webhook送信```を利用するにしてください。
ここら辺は忘れたので違かったらググってどうぞ。
# 3. オウム返し
GoogleAppsScriptにアクセスして、ログインしたら。新しいプロジェクトを生成して開きます。そして上書き。

```javascript:コード.js
var CHANNEL_ACCESS_TOKEN = '/*さっきメモったやつ*/'; 
var line_endpoint = 'https://api.line.me/v2/bot/message/reply';

//ポストで送られてくるので、ポストデータ取得
//JSONをパースする
function doPost(e) {
  var json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  var reply_token= json.events[0].replyToken;
  var user_message = json.events[0].message.text;  
  reply_message(reply_token, user_message);
}

/**文字を送る場合
 *
 * @param String reply_token : アクセストークン
 * @param String[] message : 本文
 *
 */
function reply_message(reply_token, message) {
  var postData = {
    "replyToken": reply_token,
    "messages": [{
      "type" : "text",
      "text" : "" + message
    }]
  };
  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(postData)
  };
  UrlFetchApp.fetch(line_endpoint, options);
}
```

さっきメモったやつをCHANNEL_ACCESS_TOKENに入れてください。
そしたらGoogleAppsScript(以後、GAS)の```公開>Webアプリケーションとして公開```と進んで、
バージョンは**New**、
次のユーザーとしてアプリケーションを実行は**自分(メアド)**、
アプリケーションにアクセスできるユーザーは**全員(匿名...)**、
それで公開します。出てきたURLをコピーして、LINE Developerの```Webhook URL ※SSLのみ対応 ```にペーストして前の```https://```を消して適用させてあげます。チェックマークが出てきて、接続確認が成功したら大丈夫です。
次またソースを変えて実行したくなったら公開の作業だけをしてください。生成されるURLは変わらないはずなので。```Webhook送信```をオンにしてないことが多いので気を付けて。
これでオウム返しは大丈夫なはずです。

**<解説みたいなの>**
```CHANNEL_ACCESS_TOKEN```はそのままです。
```line_endpoint```に入ってるリンクはReply送信のAPIが提供されているURLです。基本的に不変です。
```doPost```の引数、```e```には送信者のデータが詰まってます。それをJSONにパースして中にある(送ってきた人を判別する)トークンとメッセージを取ります。それを作った```reply_message```関数でJSONの中に組み入れ、```UrlFetchApp.fetch(line_endpoint, options);```でLINEに送信！あとはJSONを解釈してくれて、メッセージを飛ばしてくれるという感じです。

# 4. コマンドに反応する。

送るメッセージを変えるだけ。

```javascript:コード.js
var CHANNEL_ACCESS_TOKEN = '/**/'; 
var line_endpoint = 'https://api.line.me/v2/bot/message/reply';

//ポストで送られてくるので、ポストデータ取得
//JSONをパースする
function doPost(e) {
  var json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  var reply_token= json.events[0].replyToken;
  var user_message = json.events[0].message.text;  

  //返信する内容を作成
  var reply_messages; // 配列
  switch (user_message) {
    case 'ド': 
      reply_messages = ['はドーナツのド'];
      break;
    case 'レ':
      reply_messages = ['はレモンのレ'];
      break;
    case 'ミ':
      reply_messages = ['はみんなのミ'];
      break;
    case 'ドレミ':
      reply_messages = ['の歌'];
      break;
    default:
      reply_messages = [user_message]
      break;
  }
  reply_message(reply_token, reply_messages);
}

/**文字を送る場合
 *
 * @param String reply_token : アクセストークン
 * @param String[] message : 本文
 *
 */
function reply_message(reply_token, message) {
  var postData = {
    "replyToken": reply_token,
    "messages": [{
      "type" : "text",
      "text" : "" + message[0]
    }]
  };
  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(postData)
  };
  UrlFetchApp.fetch(line_endpoint, options);
}

```


**<解説もどき>**
``switch``文で分岐。それ以外の場合はオウム返し。```break;```を忘れずに。```reply_messages```変数は後程、いろいろなデータを詰め込みたいので配列。だから、```reply_message```関数内の```"text" : "" + message[0]```では配列```message```の一つ目を取るようにしてます。

# 5. 画像も送れるようにする
```switch```文内で画像を送るのかどうか```true of false``` ```boolean```型で表記しとく。
画像には注意も...

```javascript:コード.js
//CHANNEL_ACCESS_TOKENを設LINE developerで登録をした、自分のCHANNEL_ACCESS_TOKENを入れて下さい
var CHANNEL_ACCESS_TOKEN = ''; 
var line_endpoint = 'https://api.line.me/v2/bot/message/reply';

//ポストで送られてくるので、ポストデータ取得
//JSONをパースする
function doPost(e) {
  var json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  var reply_token= json.events[0].replyToken;
  var user_message = json.events[0].message.text;  

  // 画像を送るかどうか
  var is_image = false;
  //返信する内容を作成
  var reply_messages; // 配列
  switch (user_message) {
    case 'ド': 
      reply_messages = ['はドーナツのド'];
      is_image = false;
      break;
    case 'レ':
      reply_messages = ['はレモンのレ'];
      is_image = false;
      break;
    case 'ミ':
      reply_messages = ['はみんなのミ'];
      is_image = false;
      break;
    case 'ドレミ':
      reply_messages = ['の歌'];
      is_image = false;
      break;
    case '画像':
      reply_message = ["", "https://cdn-ak.f.st-hatena.com/images/fotolife/C/CyberHacnoshuke/20190220/20190220203854.jpg", "https://cdn-ak.f.st-hatena.com/images/fotolife/C/CyberHacnoshuke/20190220/20190220205005.jpg"];
      is_image = true;
    default:
      reply_messages = [user_message]
      is_image = false;
      break;
  }

  if (is_image) {
    // 画像
    reply_message_image(reply_token, reply_messages[0], reply_messages[1], reply_messages[2]);
  } else {
    // 画像ではない
    reply_message(reply_token, reply_messages);
  }
}

/**文字を送る場合
 *
 * @param String reply_token : アクセストークン
 * @param String[] message : 本文
 *
 */
function reply_message(reply_token, message) {
  var postData = {
    "replyToken": reply_token,
    "messages": [{
      "type" : "text",
      "text" : "" + message[0]
    }]
  };
  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(postData)
  };
  UrlFetchApp.fetch(line_endpoint, options);
}



/** 画像を送る場合
 *
 * @param String reply_token : アクセストークン
 * @param String message : 画像と一緒に送るメッセージ
 * @param String originalContentUrl : オリジナル画質の画像のURL
 * @param String previewImageUrl : プレビュー画質の画像のURL
 *
 */
function reply_message_image(reply_token, message, originalContentUrl, previewImageUrl) {
  var postData = {
    "replyToken": reply_token,
    "messages": [{
      "type" : "text",
      "text" : message
    },
    {
      "type" : "image",
      "originalContentUrl": originalContentUrl,
      "previewImageUrl": previewImageUrl
    }]
  };
  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(postData)
  };
  UrlFetchApp.fetch(line_endpoint, options);
}
```

送る画像には制限があります。
まず、必ず```https://```のURLにしてください。hatena blogとかでアップロードしてURLを取得できます。(URLは開いたときこんな感じになるものにしてください。はてなブログの教えてくれるURLではなく右クリックしてアドレスを取得してくださいね。)

![image.png](https://qiita-image-store.s3.amazonaws.com/0/263233/9c7f0c57-42a7-6c27-f4de-a0b593fd1976.png)

次に容量とサイズの制限です。

|      |      メイン画像 |    プレビュー画像    |
|:-----------------|------------------:|:------------------:|
| 最大画像サイズ             |              1024×1024|        240×240|
| 最大ファイルサイズ          |            1MB |       1MB       |
こんなかんじのめやすを付けといてください。
**必ずプレビュー画像を付けてください。**片方がないとダメみたいです。

# 6. データベースをユーザーごとに用意する。
スプレッドシートで```Master```のファイル(コピー元)を書き終わったらWeb上に公開してください。そのURLは```https://docs.google.com/spreadsheets/d/XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx/edit```みたいな感じになります。

データベース関連のソース

```javascript
// プロファイル取得用のAPIのURL(Replyと同じ感じ)
var line_endpoint_profile = 'https://api.line.me/v2/bot/profile';

/** ユーザーのスプレッドシートを取得。なかったらcreateSpreadSheetを呼んで生成。
 * 
 * @param user_id ユーザーのID
 *
 * @return スプレッドシート
 */
function getSpreadSheet(user_id) {
  var sid = PropertiesService.getScriptProperties().getProperty(user_id);
  if (sid == null) {
    return createSpreadSheet(user_id);
  } else {
    try {
      return SpreadsheetApp.openById(sid);
    } catch(e) {
      return createSpreadSheet(user_id);
    }
  }
}


/** ユーザーのスプレッドシートを生成。名前はユーザー名。権限なしアクセスを許可して共有。
 * 
 * @param user_id ユーザーのID
 *
 * @return スプレッドシート
 */
function createSpreadSheet(user_id) {
  var ori_sheet = SpreadsheetApp.openById('XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx');
  var spreadSheet = ori_sheet.copy(getUserDisplayName(user_id));
  PropertiesService.getScriptProperties().setProperty(user_id, spreadSheet.getId());
  var file = DriveApp.getFileById(spreadSheet.getId());
  file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  
  return spreadSheet;
}

/** ユーザーのアカウント名を取得。
 * 
 * @param user_id ユーザーのID
 *
 * @return アカウント名
 */
function getUserDisplayName(user_id) {
  var res = UrlFetchApp.fetch(line_endpoint_profile + '/' + user_id, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN,
    },
    'method': 'get',
  });
  return JSON.parse(res).displayName;
}

// 自分でスプレッドシート関連の関数作るときに参考にしてください。
// 
// var SpreadSheet = getSpreadSheet(user_id);
// var sheet = SpreadSheet.getSheetByName('シート1');
// これでシートを取得
// sheet.getRange(/*縦の数字*/, /*横の数字*/).getValue();
// sheet.getRange(1, 2).getValue();
// これでB1の値を取得
//
// sheet.getRange("B1").getValue();
// これも同じ意味


/** 特定のユーザーのスプレッドシートを削除(ゴミ箱だとまだアクセスできるので、ゴミ箱からも消します。)
 *
 * @param user_id ユーザーのID
 */
function deleteData(user_id) {
  var SpreadSheet = getSpreadSheet(user_id);
  //ゴミ箱に入れずに削除
  Drive.Files.remove(SpreadSheet.getId());
}
```

関数ごとの説明は上記の通り。```createSpreadSheet```関数内の```var ori_sheet = SpreadsheetApp.openById('XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx');```ではオリジナルシート(```Master```)を取得しています。

``XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx``にはスプレッドシートを公開したときの```https://docs.google.com/spreadsheets/d/XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx/edit```の該当部分を入れてください。

# 7. 全部

```javascript
var CHANNEL_ACCESS_TOKEN = ''; 
var line_endpoint = 'https://api.line.me/v2/bot/message/reply';
var line_endpoint_profile = 'https://api.line.me/v2/bot/profile';

//ポストで送られてくるので、ポストデータ取得
//JSONをパースする
function doPost(e) {
  var json = JSON.parse(e.postData.contents);

  //返信するためのトークン取得
  var reply_token= json.events[0].replyToken;
  //user_idはこうやってとる
  var user_id = json.events[0].source.userId;
  var user_message = json.events[0].message.text;  


  // 画像を送るかどうか
  var is_image = false;
  //返信する内容を作成
  var reply_messages; // 配列
  switch (user_message) {
    case 'ド': 
      reply_messages = ['はドーナツのド'];
      is_image = false;
      break;
    case 'レ':
      reply_messages = ['はレモンのレ'];
      is_image = false;
      break;
    case 'ミ':
      reply_messages = ['はみんなのミ'];
      is_image = false;
      break;
    case 'ドレミ':
      reply_messages = ['の歌'];
      is_image = false;
      break;
    case '画像':
      reply_message = ["", "https://cdn-ak.f.st-hatena.com/images/fotolife/C/CyberHacnoshuke/20190220/20190220203854.jpg", "https://cdn-ak.f.st-hatena.com/images/fotolife/C/CyberHacnoshuke/20190220/20190220205005.jpg"];
      is_image = true;
      break;
    case 'スプレッドシート':
      //リンクを取得する
      var SpreadSheet = getSpreadSheet(user_id);
      reply_messages = [SpreadSheet.getUrl()];
      is_image = false;
      break;
    default:
      reply_messages = [user_message]
      is_image = false;
      break;
  }
  if (is_image) {
    reply_message_image(reply_token, reply_messages[0], reply_messages[1], reply_messages[2]);
  } else {
    reply_message(reply_token, reply_messages);
  }
}

/**文字を送る場合
 *
 * @param String reply_token : アクセストークン
 * @param String[] message : 本文
 *
 */
function reply_message(reply_token, message) {
  var postData = {
    "replyToken": reply_token,
    "messages": [{
      "type" : "text",
      "text" : "" + message[0]
    }]
  };
  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(postData)
  };
  UrlFetchApp.fetch(line_endpoint, options);
}



/** 画像を送る場合
 *
 * @param String reply_token : アクセストークン
 * @param String message : 画像と一緒に送るメッセージ
 * @param String originalContentUrl : オリジナル画質の画像のURL
 * @param String previewImageUrl : プレビュー画質の画像のURL
 *
 */
function reply_message_image(reply_token, message, originalContentUrl, previewImageUrl) {
  var postData = {
    "replyToken": reply_token,
    "messages": [{
      "type" : "text",
      "text" : message
    },
    {
      "type" : "image",
      "originalContentUrl": originalContentUrl,
      "previewImageUrl": previewImageUrl
    }]
  };
  var options = {
    "method": "post",
    "headers": {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + CHANNEL_ACCESS_TOKEN
    },
    "payload": JSON.stringify(postData)
  };
  UrlFetchApp.fetch(line_endpoint, options);
}

/** ユーザーのスプレッドシートを取得。なかったらcreateSpreadSheetを呼んで生成。
 * 
 * @param user_id ユーザーのID
 *
 * @return スプレッドシート
 */
function getSpreadSheet(user_id) {
  var sid = PropertiesService.getScriptProperties().getProperty(user_id);
  if (sid == null) {
    return createSpreadSheet(user_id);
  } else {
    try {
      return SpreadsheetApp.openById(sid);
    } catch(e) {
      return createSpreadSheet(user_id);
    }
  }
}


/** ユーザーのスプレッドシートを生成。名前はユーザー名。権限なしアクセスを許可して共有。
 * 
 * @param user_id ユーザーのID
 *
 * @return スプレッドシート
 */
function createSpreadSheet(user_id) {
  var ori_sheet = SpreadsheetApp.openById('XxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXxXx');
  var spreadSheet = ori_sheet.copy(getUserDisplayName(user_id));
  PropertiesService.getScriptProperties().setProperty(user_id, spreadSheet.getId());
  var file = DriveApp.getFileById(spreadSheet.getId());
  file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);

  return spreadSheet;
}

/** ユーザーのアカウント名を取得。
 * 
 * @param user_id ユーザーのID
 *
 * @return アカウント名
 */
function getUserDisplayName(user_id) {
  var res = UrlFetchApp.fetch(line_endpoint_profile + '/' + user_id, {
    'headers': {
      'Content-Type': 'application/json; charset=UTF-8',
      'Authorization': 'Bearer ' + CHANNEL_ACCESS_TOKEN,
    },
    'method': 'get',
  });
  return JSON.parse(res).displayName;
}

// 自分でスプレッドシート関連の関数作るときに参考にしてください。
// 
// var SpreadSheet = getSpreadSheet(user_id);
// var sheet = SpreadSheet.getSheetByName('シート1');
// これでシートを取得
// sheet.getRange(/*縦の数字*/, /*横の数字*/).getValue();
// sheet.getRange(1, 2).getValue();
// これでB1の値を取得
//
// sheet.getRange("B1").getValue();
// これも同じ意味


/** 特定のユーザーのスプレッドシートを削除(ゴミ箱だとまだアクセスできるので、ゴミ箱からも消します。)
 *
 * @param user_id ユーザーのID
 */
function deleteData(user_id) {
  var SpreadSheet = getSpreadSheet(user_id);
  //ゴミ箱に入れずに削除
  Drive.Files.remove(SpreadSheet.getId());
}

```

こんな感じです。書き込みなどは自由で。

# 8. まとめ
わずか一日という短期間で仕上げたのでこの記事に間違いがあるかもしれませんが、指摘していただけるとありがたいです。いまはやりのLINE。そのBotアカウント、作ってみませんか。

Twitter: https://twitter.com/Cyber_Hacnosuke (フォローしてくださいお願いします。)
Github: https://github.com/CyberHacnoshuke