LoginSignup
4
1

More than 3 years have passed since last update.

日報作成LINEボット

Last updated at Posted at 2020-03-30

はじめに

コロナウイルス早く終息して欲しい!
元同僚に会社がテレワークにまだ移行しておらず、テレワークで仕事は可能なのに出勤しなければいけないと聞いたので、少しでも上司に安心してもらって移行してもらえるように、そして感染拡大を少しでも防ぐ為に今ハマってるIFROを使って、流行り?の分報ならぬ時報ボットを作ってみようと思います!

そして、コストがかかるのも抵抗があると思うので(LINEのプッシュ料金)は気にはなるものの、実際に報告書を作るプログラムはGoogleAppsScriptを使っていこうと思います。

今回作ったもの

IFROテンプレートストアの「報告安心ボット」って名前のやつ
GoogleAppsScript
LINEのボット(サンプル)
428cplwq.png
※このサンプルは無料のアカウントなのでプッシュ上限になったら報告書の送信と催促の通知は送られません。。。

ボットの役割(ざっくり)

  • 報告を取りまとめる。
  • 前回の報告から一定の時間経ったら報告を催促する。
  • 仕事が終わったら報告書を上司に送る。

ボットが作ってくれる成果物

こんな感じのGoogle Documentの業務日報

スクリーンショット 2020-03-31 12.30.38.png

LINE

まずは報告用のボットのアカウントを作っていきます。
これは特に難しいことはないですね。過去の記事プログラミング無しでのLINE BOTの作り方でやったようにアカウントを作っていきます。

スクリーンショット 2020-03-28 10.19.35.png

チャネル名や説明は適当に。

スクリーンショット 2020-03-29 02.53.26.png

今回は挨拶文もちょっと変えておきます。
最初の発話でFlex Messageを送ったり出来ないので、一応発話コマンドを載せておくことにします。

IFRO①

プロジェクトの作成

スクリーンショット 2020-03-28 9.39.12.png

「+新規スキル」をクリックして新規プロジェクトを作ります。

スクリーンショット 2020-03-28 15.04.34.png

プロパティを開いてスキル名を付けます。

スクリーンショット 2020-03-29 01.44.54.png

続いて、今のところ思いつくプロパティを設定します。
プロパティには、あとから簡単に変更したい値や繰り返し発話する発話内容などを設定しておくと便利です。

  • 催促の間隔 : 最後のユーザーアクションから何分後に催促するか
  • 報告を送る上司のLINE uid : 報告を上司に送る為に上司のIDが必要ですので、それを設定するプロパティです。
・ 業務開始 > 報告書の作成を始める。
・ 休憩開始 > 催促を止める。
・ 休憩終了 > 催促を再開する。
・ 業務終了 > 報告書を上司に提出する。
・ 上記以外の発話(報告) > 報告書に書く。

LINEの挨拶文にも書いた通り、このような感じでBotに反応させたいので構成は下記のような感じですかね。

① 聞き取り
  ↓
② 記憶保存
  ↓
③ 記憶出力
  ↓
④ 分 岐
  ↓
⑤
  (業務開始してない)業務開始 > 報告書の雛形をコピーして名前をつける。
  (業務開始している且つ休憩中じゃない)休憩開始 > 催促用のトリガーを止める。
  (業務開始している且つ休憩中)休憩終了 > 催促用のトリガーをスタートする。
  (業務開始している)業務終了 > 報告書をPDFで保存して上司にPushで送る。
  (業務開始している)上記以外の発話(報告) > 報告書の雛形に時間と共に書き込む
  業務開始してなかったら業務開始ボタンを表示する。
  ↓
⑥ 何かしら発話しつつ、報告以外の発話のボタンを表示する。
  ↓
⑦ ①へ戻る

それでは、早速作っていきたいと思います

スクリーンショット 2020-03-29 02.55.18.png
一番上の階層はこんな感じ。
「発話内容」という記憶を作り、発話内容を保存出来るようにします。
結構ボックスが多くなりそうなので、グループモジュールを使って見た目をシンプルにしていきます。

スクリーンショット 2020-03-30 17.31.48.png

「業務開始」と発話された場合のグループです。
業務中かどうかを判定する記憶が必要なので、プロパティとうまく組み合わせます。
業務を開始したらプロパティ出力モジュールを使って「業務中フラグ」プロパティを出力し、「業務フラグ」という記憶に保存します。
また、休憩中かどうかも判定し、状態にあった対応を取れるように「休憩中フラグ」も使っていきます。
メインとなるWebhookモジュールとメッセージオブジェクト出力モジュール(LINE)はあとからいれることにします。

スクリーンショット 2020-03-30 17.34.10.png

初回だけ名前と部署を聞くようにします。
初期値だったら質問を発話、初期値以外だったら次の質問へ・・・といった感じです。

スクリーンショット 2020-03-29 03.15.21.png
「休憩開始」と発話された場合のグループです。
業務開始と同様、業務フラグを判定し処理を分けていきます。
ちなみに分岐から先は、左から1(業務中)、2(業務終了)、3(休憩中)って感じに統一してボックスを置いていってます。

スクリーンショット 2020-03-29 03.39.13.png
続いて、「休憩終了」のグループ。
大体似たような構造です。

スクリーンショット 2020-03-29 03.43.40.png
こちらは「業務終了」と発話された場合のグループです。
とりあえず今のところ説明は不要ですね。

スクリーンショット 2020-03-29 03.47.32.png
最後、「報告」のグループです。

メッセージオブジェクト出力モジュール(LINE)の作成

報告以外のコマンドは、発話でもいいけどボタンでも出来るようにしておきたいのでFlex Messageを使ってBot側の発話を作っていきます。

Flex Message Simulatorを使えば簡単に作れるので、ササッと作っちゃいましょう。
凝ったものを作ろうと思えばいくらでも出来るっぽいのですが、今回はあまり重視しなくて良いと思うのでシンプルに作っていきます。

スクリーンショット 2020-03-29 04.09.11.png
こんな感じでいいかなと思います。

右上の「View as JSON」を押すと下の画像にような感じになるのでCopyを押します。

スクリーンショット 2020-03-29 04.10.43.png

このコピーしたJSONをメッセージオブジェクト出力モジュール(LINE)の中に貼り付けて「ここに発話など文章」に部分を適宜文章を変えていこうと思います。
それぞれのケースに合った文章にすれば良いと思うので全て載せるのは割愛しますが、この部分をプロパティにしておくことでプロパティの設定部分からあとから文章を簡単に変えるようにすることも出来ます。
また、ケースによって表示不要なボタンもあると思うので、外してあげるとUX的に良い感じにもなる気がします。

スクリーンショット 2020-03-29 04.16.14.png
こういう感じで全てのメッセージオブジェクト出力モジュール(LINE)を埋めていきます。

この作業が終われば、設定してないボックスはWebhookモジュールのみになりますが、先にGoogleAppsScriptを書いていこうと思います。
Webhookモジュールの設定は#IFRO②で書きます。

GoogleAppsScript

実装する機能としては下記の6つになると思いますので、順番に書いていこうと思います。

1 報告書を作成する。
2 休憩開始。
3 休憩終了。
4 報告を書く。
5 報告書を上司に提出する。

コピー用スクリプトソースコードのリンクを置いておきます。
※当方、素人なので割とごちゃごちゃしています。すみません。
コピーじゃない場合は、Momentを使っているので、ライブラリにMHMchiX6c1bwSqGM1PZiW_PxhMjh3Sh48を追加します。

main.gs
function doPost(e){
  var pid = JSON.parse(e.parameter.parameter1).pid;
  var department = decodeURI(JSON.parse(e.parameter.parameter1).department);
  var name = decodeURI(JSON.parse(e.parameter.parameter1).name);
  var directory_id = decodeURI(JSON.parse(e.parameter.parameter1).directory_id);
  setProp("add_minutes", JSON.parse(e.parameter.parameter2).add_minutes);

  switch (pid){
    case 1:
      createDocument(name, department, directory_id);
      addLine(name, department, directory_id, "業務開始");
      setTrigger(decodeURI(JSON.parse(e.parameter.parameter2).usr_uid), getProp("add_minutes"));
      break;
    case 2:
      addLine(name, department, directory_id, "休憩開始");
      delTrigger(decodeURI(JSON.parse(e.parameter.parameter2).usr_uid), getProp("add_minutes"));
      break;
    case 3:
      addLine(name, department, directory_id, "休憩終了");
      setTrigger(decodeURI(JSON.parse(e.parameter.parameter2).usr_uid), getProp("add_minutes"));
      break;
    case 4:
      addLine(name, department, directory_id, decodeURI(JSON.parse(e.parameter.parameter2).report_content));
      setTrigger(decodeURI(JSON.parse(e.parameter.parameter2).usr_uid), getProp("add_minutes"));
      break;
    case 5:
      addLine(name, department, directory_id, "業務終了");
      setProp("access_token", decodeURI(JSON.parse(e.parameter.parameter2).access_token));
      sendReport(name, department, directory_id, decodeURI(JSON.parse(e.parameter.parameter2).mng_uid), getProp("access_token"));
      delTrigger(decodeURI(JSON.parse(e.parameter.parameter2).mng_uid), getProp("add_minutes"));
    default:
      break;
  }
}

1〜4 報告書を作成する〜報告まで

1〜4までは、ほぼ同じなのでまとめて書きます。

    case 1:
      createDocument(name, department, directory_id);
      addLine(name, department, directory_id, "業務開始");
      setTrigger(decodeURI(JSON.parse(e.parameter.parameter2).usr_uid), getProp("add_minutes"));
      break;

createDocumentで日報用のGoogleDocumentを作成していきます。

control_document.gsより

function createDocument(name, department, directory_id){
  //日報の名前を付ける
  var d = Moment.moment().format("YYYY年M月D日");
  var fName = d + department + name;

  //ドキュメントを格納するフォルダを取得or作成
  var targetFolder = getFolder(directory_id, Moment.moment());
  //フォルダにドキュメントが存在するか確認
  var docExists = getFileId(targetFolder, Moment.moment().format("YYYY年M月D日") + department + name);
  //なければドキュメントを作成
  if (!docExists){

    //ドキュメントの作成
    var doc = DocumentApp.create(fName);
    var docBody = doc.getBody();

    /*ドキュメントの初期設定*/
    //タイトル
    var p_title = docBody.appendParagraph("業務日報\n");
    p_title.setFontSize(18);
    p_title.setAlignment(DocumentApp.HorizontalAlignment.CENTER);
    //日付
    var p_day = docBody.appendParagraph(d);
    p_day.setFontSize(11);
    p_day.setAlignment(DocumentApp.HorizontalAlignment.RIGHT);
    //部署と名前
    var p_department = docBody.appendParagraph("部 署:" + department);
    p_department.setAlignment(DocumentApp.HorizontalAlignment.RIGHT);
    var p_name = docBody.appendParagraph("氏 名:" + name + "\n");
    p_name.setAlignment(DocumentApp.HorizontalAlignment.RIGHT);

    //セーブ
    doc.saveAndClose();

    //格納するフォルダへ移動
    var docFile = DriveApp.getFileById(doc.getId());
    targetFolder.addFile(docFile);

    //ルート直下のファイルを消す
    DriveApp.removeFile(docFile);
  }
}
common.gsより
function getFolder(directory_id, d){
  //フォルダオブジェクトを取得
  var parentFolder = DriveApp.getFolderById(directory_id);

  //親フォルダのフォルダイテレータリストを取得
  var iteratorList = parentFolder.getFolders();

  //保存するフォルダを設定、すでにあればオブジェクト取得、無ければ作成してオブジェクト取得
  var saveFolder;
  var saveFoldersName = 'DailyReports';
  var folderExists = false;

  while (iteratorList.hasNext()){
    saveFolder = iteratorList.next();
    if(saveFolder.getName() == saveFoldersName){
      folderExists = true;
      break;
    }
  }
  if (!folderExists){
    saveFolder = parentFolder.createFolder(saveFoldersName);
  }

  //保存フォルダのフォルダイテレータリストを取得
  iteratorList = saveFolder.getFolders();

  //今日のフォルダを設定、すでにあればオブジェクト取得、無ければ作成してオブジェクト取得
  var todayFolder;
  var todayFoldersName = d.format("YYYY-MM-DD");
  folderExists = false;

  while (iteratorList.hasNext()){
    todayFolder = iteratorList.next();
    if(todayFolder.getName() == todayFoldersName){
      folderExists = true;
      break;
    }
  }
  if (!folderExists){
    todayFolder = saveFolder.createFolder(todayFoldersName);
    todayFolder.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
  }
  return todayFolder;
}
common.gsより
function getFileId(folder, title){
  //親フォルダのファイルイテレータリストを取得
  var iteratorList = folder.getFiles();

  //オブジェクトを検索
  var docFile;
  var fileExists = false;

  while (iteratorList.hasNext()){
    docFile = iteratorList.next();
    if(docFile.getName() == title){
      fileExists = true;
      break;
    }
  }
  if (!fileExists){
    return false;
  }
  return docFile.getId();
}

ドキュメントを作成したら、addLINEを呼び出して、ドキュメントに「業務開始」と書き込みます。

control_document.gsより
function addLine(name, department, directory_id, report){
  //ドキュメントを格納されているフォルダオブジェクトを取得
  var targetFolder = getFolder(directory_id, Moment.moment());

  //ドキュメントオブジェクトを取得
  var doc_id = getFileId(targetFolder, Moment.moment().format("YYYY年M月D日") + department + name);

  if(doc_id){
    DocumentApp.openById(doc_id).getBody().appendParagraph(Moment.moment().format("HH:mm:ss") + "\t" + report + "\n");
  }
}

書き込みが終わったら、トリガーの時間を設定します。

trigger.gsより
function setTrigger(uuid, addMinutes){
  //このスクリプトのスクリプトIDを取得し、このスクリプトがある親フォルダのオブジェクトを取得します。
  var script_id = ScriptApp.getScriptId();
  var parentFolderIterator = DriveApp.getFileById(script_id).getParents();
  var parentFolderId = parentFolderIterator.next().getId();
  var parentFolder = DriveApp.getFolderById(parentFolderId);
  //親フォルダに入っているファイルイテレータリストを取得、指定の名前のスプレッドシートがあるかどうか検索します。
  var iteratorList = parentFolder.getFiles();

  var ss, spreadsheet;
  var fileExists = false;

  while (iteratorList.hasNext()){
    ss = iteratorList.next();
    if (ss.getName() == "Push管理用"){
      fileExists = true;
      break;
    }
  }
  //スプレッドシートがなければ作成し、スプレッドシートオブジェクトをspreadsheet変数に入れます。
  if (!fileExists){
    spreadsheet = SpreadsheetApp.create("Push管理用");
  }else{
    spreadsheet = SpreadsheetApp.openById(ss.getId());
  }
  //スプレッドシートに次に催促する時間を設定します。
  var sheets = spreadsheet.getSheets();

  for(var i in sheets){
    var sheet = sheets[i];
    if(sheet.getSheetId() == 0){
      var row = findRow(sheet, uuid, 1);
      if (row == 0){
        sheet.appendRow([uuid, Moment.moment().add(parseInt(addMinutes), "m").format("YYYY/MM/DD HH:mm:ss"), true]);
      }else{
        sheet.getRange(row, 2).setValue(Moment.moment().add(parseInt(addMinutes), "m").format("YYYY/MM/DD HH:mm:ss"));
        sheet.getRange(row, 3).setValue(true);
      }
    }
  }
  if (!fileExists){
    parentFolder.addFile(DriveApp.getFileById(spreadsheet.getId()));
    DriveApp.removeFile(DriveApp.getFileById(spreadsheet.getId()));
  }
}

2と5 休憩終了と業務終了のdelTrigger

setTriggerとほとんど同じです。今見たら参照する列の値をfalseに変えるだけですね。引数を変えるだけでよかったですね。。

common.gsより
function delTrigger(uuid, addMinutes){
  var script_id = ScriptApp.getScriptId();
  var parentFolderIterator = DriveApp.getFileById(script_id).getParents();
  var parentFolderId = parentFolderIterator.next().getId();
  var parentFolder = DriveApp.getFolderById(parentFolderId);

  var iteratorList = parentFolder.getFiles();

  var ss, spreadsheet;
  var fileExists = false;

  while (iteratorList.hasNext()){
    ss = iteratorList.next();
    if (ss.getName() == "Push管理用"){
      fileExists = true;
      break;
    }
  }
  if (!fileExists){
    spreadsheet = SpreadsheetApp.create("Push管理用");
  }else{
    spreadsheet = SpreadsheetApp.openById(ss.getId());
  }

  var sheets = spreadsheet.getSheets();

  for(var i in sheets){
    var sheet = sheets[i];
    if(sheet.getSheetId() == 0){
      var row = findRow(sheet, uuid, 1);
      if (row == 0){
        sheet.appendRow([uuid, Moment.moment().add(parseInt(addMinutes), "m").format("YYYY/MM/DD HH:mm:ss"), false]);
      }else{
        sheet.getRange(row, 2).setValue(Moment.moment().add(parseInt(addMinutes), "m").format("YYYY/MM/DD HH:mm:ss"));
        sheet.getRange(row, 3).setValue(false);
      }
    }
  }
  if (!fileExists){
    parentFolder.addFile(DriveApp.getFileById(spreadsheet.getId()));
    DriveApp.removeFile(DriveApp.getFileById(spreadsheet.getId()));
  }
}

5 報告書を上司に提出する。

    case 5:
      addLine(name, department, directory_id, "業務終了");
      setProp("access_token", decodeURI(JSON.parse(e.parameter.parameter2).access_token));
      sendReport(name, department, directory_id, decodeURI(JSON.parse(e.parameter.parameter2).mng_uid), getProp("access_token"));
      delTrigger(decodeURI(JSON.parse(e.parameter.parameter2).mng_uid), getProp("add_minutes"));
      break;

sendReportだけが1〜4と違うので書くと

control_document.gsより
function sendReport(name, department, directory_id, uid, channel_access_token){
  var title = Moment.moment().format("YYYY年M月D日") + department + name;
  //ドキュメントを格納されているフォルダオブジェクトを取得
  var targetFolder = getFolder(directory_id, Moment.moment());

  //ドキュメントオブジェクトを取得
  var doc_id = getFileId(targetFolder, title);

  if(doc_id){
    var doc = DocumentApp.openById(doc_id);
    //ファイルのURLを取得(Androidだとエラーで落ちる機種があるので不要な部分をトリミング)
    var fileUrl = doc.getUrl().replace(/\?usp=drivesdk$/, "");

    var message = department + " / " + name + "さんの日報が届きました。\nご確認ください。"
    //Pushメソッドを呼ぶ
    push(uid.split(","), message, channel_access_token, fileUrl);    
  }
}
common.gsより
function push(uid, message, channel_access_token, fileUrl) {
  var line_endpoint = "https://api.line.me/v2/bot/message/multicast";
  //引数にURLが渡されていたらFlex Messageで送ります。違ったらただのテキストで。
  if (fileUrl == ""){
    var postData = {
      "to" : uid,
      "messages" : [
        {
          "type" : "text",
          "text" : message,
        }
      ]
    };
  }else{
    var postData = {
      "to" : uid,
      "messages" : [
        {
          "type": "flex",
          "altText": message + "\n" + fileUrl,
          "contents": {
            "type": "bubble",
            "direction": "ltr",
            "body": {
              "type": "box",
              "layout": "vertical",
              "contents": [
                {
                  "type": "text",
                  "text": message,
                  "align": "start",
                  "gravity": "top",
                  "wrap": true
                },
                {
                  "type": "button",
                  "action": {
                    "type": "uri",
                    "label": "業務日報を見てみる",
                    "uri": fileUrl
                  },
                  "margin": "lg",
                  "style": "primary"
                }
              ]
            }
          }
        }
      ]
    };
  }
  var options_push = {
    "method" : "post",
    "headers" : {
      "Content-Type" : "application/json",
      "Authorization" : "Bearer " + channel_access_token,
    },
    "payload" : JSON.stringify(postData)
  };
  UrlFetchApp.fetch(line_endpoint, options_push);
}

その後の操作の説明です。

IFROに貼り付けるURLを取得する。

スクリーンショット 2020-03-30 17.47.07.png
「公開」 -> 「ウェブアプリケーションとして導入」
スクリーンショット 2020-03-30 17.47.18.png
「更新」を押します。
スクリーンショット 2020-03-30 17.49.30.png
自分のGoogleアカウントでログインします。
スクリーンショット 2020-03-30 17.49.40.png
「詳細を表示」を押した後、下に表示される「コピー〜LINE日報」に移動を押します。
スクリーンショット 2020-03-30 17.49.53.png
下にある「許可」を押すとURLが発行されます。
スクリーンショット 2020-03-30 17.47.30.png
この https ://script.google.com/macros/s/ から始まるURLをコピーしておきます。
このURLはあとでIFROのWebhookモジュールに貼り付けます。

催促用のトリガーを設定する。

trigger.gsより
//このメソッドを1分置きに実行
function reminder(){
  //管理用スプレッドシートを開く
  var script_id = ScriptApp.getScriptId();
  var parentFolderIterator = DriveApp.getFileById(script_id).getParents();
  var parentFolderId = parentFolderIterator.next().getId();
  var parentFolder = DriveApp.getFolderById(parentFolderId);

  var iteratorList = parentFolder.getFiles();

  var ss;
  var fileExists = false;

  while (iteratorList.hasNext()){
    ss = iteratorList.next();
    if (ss.getName() == "Push管理用"){
      fileExists = true;
      break;
    }
  }

  if (!fileExists){
    return;
  }

  var sheets = SpreadsheetApp.openById(ss.getId()).getSheets();
  //スプレッドシートの値を見ていき、催促する時間になっていたらPush送信を行う。
  for(var i in sheets){
    var sheet = sheets[i];
    if(sheet.getSheetId() == 0){
      var data = sheet.getDataRange().getValues(); 
      var sendList = "";

      for(var j in data){
        if(data[j][2] && Moment.moment(data[j][1]).isBefore(Moment.moment())){
          if(j == 0){
            sendList = data[j][0];
          }else{
            sendList += "," + data[j][0];
          }
          setTrigger(data[j][0], getProp("add_minutes"));
        }
      }
      //console.log("sendList : " + sendList);
      if(sendList.length != 0){
        var message = "今、何してますか?そろそろ一度報告してね!"
        var access_token = getProp("access_token");
        if (access_token != 0){
          push(sendList.split(","), message, access_token, "");
        }
      }
    }
  }
}

スクリーンショット 2020-03-30 17.59.18.png
この「時計」マークを押します。
すると別ウィンドウが立ち上がり、トリガーの設定画面になります。
スクリーンショット 2020-03-30 18.00.06.png
右下の「+トリガーを追加」ボタンを押します。
スクリーンショット 2020-03-30 18.01.07.png

  • 実行する関数:reminder
  • 実行するデプロイを選択:Head
  • イベントのソースを選択:時間主導型
  • 時間ベースのトリガーのタイプを選択:分ベースのタイマー
  • 時間の間隔を選択(分):1分おき

保存ボタンを押します。

IFRO②

最終的なプロパティ

スクリーンショット 2020-03-30 18.20.47.png

  • 上司のLINE uidは、以下で取得出来ます。

スクリーンショット 2020-03-30 18.21.03.png
LINE Developersにログインすると「チャネル基本設定」の最下部にあります。
上司にLINE IDでログインしてもらわなきゃいけませんね。。。
取得する方法を追加しておきます。

〜追加〜
「:LINE_ID:」とLINEボットに発話するとあなたのLINE IDを発話します。ので、上司に「:LINE_ID:」と言ってもらって返ってきたIDをプロパティに入れてください。

  • GoogleDriveのフォルダIDは、以下で取得出来ます。

スクリーンショット 2020-03-30 18.25.14.png

赤丸で囲んだ部分です。

URLを入れていく。

スクリーンショット 2020-03-30 18.14.28.png
残念ながらURLをプロパティにいれることが出来ない仕様な為、Webhookモジュールに先ほどコピーしたURLを入れていきます。
parameterは特にいれる必要はありません。
全部で5個のWebhookモジュールがあるので忘れずに!

以上で、終わりです!
スクリーンショット 2020-03-31 12.37.30.png
〜〜〜
スクリーンショット 2020-03-31 12.37.47.png

こんな感じです。

最後、ちょっと手抜きしましたが、わかりにくい部分があったら修正しますので言ってもらえたら。

追記

管理用のスプレッドシートを作成するコードが間違ってたので修正しました。

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1