Help us understand the problem. What is going on with this article?

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

1. 始めに

1.1 前提

うちの文化祭で非公式のクイズBotを作りたいと発注が来まして、スクレイピングでやってた研究と文化祭のオフィシャルアプリと、Android AIと、いろいろ仕事と学業があるなか、無理やり引き継いだのがLINEbotの制作です。具体的には言えないのですが、それを作るために必要なものをかき集めてまとめました。

1.2 そもそもどうゆうの?

①GUI

LINEです。はい。

②処理

サーバーで行います。サーバーを立てれるのはいいのですけど維持費がきついし、公開めんどい。
そこで出てきたのが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. 初期設定いろいろしたらおkです。 2019-02-20.png

こんな感じ。そしたら下のアクセストークン(ロングターム)を再発行してもらいます。失効は0時間。これで再発行されたトークンをメモ。
次に、Webhook送信を利用するにしてください。
ここら辺は忘れたので違かったらググってどうぞ。

3. オウム返し

GoogleAppsScriptにアクセスして、ログインしたら。新しいプロジェクトを生成して開きます。そして上書き。

コード.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. コマンドに反応する。

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

コード.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型で表記しとく。
画像には注意も...

コード.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

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

メイン画像 プレビュー画像
最大画像サイズ 1024×1024 240×240
最大ファイルサイズ 1MB 1MB

こんなかんじのめやすを付けといてください。
必ずプレビュー画像を付けてください。片方がないとダメみたいです。

6. データベースをユーザーごとに用意する。

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

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

// プロファイル取得用の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. 全部

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away