8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Lineでタスク管理

Posted at

gasを使用して、lineでタスク管理するツールを作ってみました!

##仕様

Lineでタスク名、完了期限をメッセージ送信するとタスクが登録されます。
タスクの確認、完了タスクの削除、リマインドが可能です。

##動作させる為に必要な準備
[1.Messaging Apiの作成](#Messaging Apiの作成)
2.マスタシートの準備
3.ソースの準備
4.スクリプトプロパティの登録
5.以降で動作確認、プログラムの簡単な説明

##Messaging Apiの作成
やる事としては、

  1. Lineコンソールへログイン
  2. プロバイダの新規作成
  3. チャネル(Messaging API)の作成
    です。
    下記サイトを参考にLine関係の準備をしてください!

###設定について
Messaging Apiの作成が終わると下記のような画面になると思います。
まず、Messaging Api設定タブを開きます。
スクリーンショット 2021-09-20 10.33.28.png

下の方に行くと、応答メッセージというのがあるので「無効」にしてしまいましょう。
編集リンク押下で編集出来ます!
スクリーンショット 2021-09-20 10.42.09.png

編集ボタン押下でこんな画面が出るので、下記のように変更してください。
「あいさつメッセージ」はどっちでも良いです!
変更したらこのページは閉じてOKです。
スクリーンショット 2021-09-20 10.51.09.png

次は、アクセストークンを発行しましょう!
発行ボタンを押せば完了です。このトークンを使用してAPIアクセス出来ます!
スクリーンショット 2021-09-20 10.55.30.png

ついでに上の方に戻るとQRコードあるので、友達追加しておいてください。

##マスタシートの準備
マスタはスプレッドシートを使用します。
下記のように、「タスク」シートと「メッセージ」シートを作って下さい。
スクリーンショット 2021-10-25 16.07.00.png
スクリーンショット 2021-10-25 16.07.36.png

「タスク」シートは実際にタスクが登録されるマスタです。

「メッセージ」シートは、受信メッセージによって処理を決定するためのマスタです。
例えば、Lineで「今のタスク」と入力するとshorikbnとして"3"が処理に渡ります。

今回の実装では下記区分値とさせていただいております。
1:タスクの登録
2:タスクの削除
3:登録されているタスクの照会

メッセージを追加することで、色々なメッセージに対応出来るようになります。

##ソース
今回実装したコードになります。
上記で作成したスプレッドシートからスクリプトエディタを開き、コピペして下さい。

main.gs
main.gs

//postリクエストを受取ったときに発火する関数
function doPost(e) {
  // 応答用Tokenを取得。
  const replyToken = JSON.parse(e.postData.contents).events[0].replyToken;
  // メッセージを取得
  const userMessage = JSON.parse(e.postData.contents).events[0].message.text;
  try {

    //メッセージを改行ごとに分割
    const allMsg = userMessage.split(NEW_LINE);
    // スプレッドシートの取得
    const spreadSheet = SpreadsheetApp.getActiveSpreadsheet();

    // 処理区分の取得
    const shoriKbn = shoriKbnGet(spreadSheet.getSheetByName(SHEET_MESSAGE), allMsg[0]);
    // データを書き込むスプレッドシートを定義
    const taskSheet = spreadSheet.getSheetByName(SHEET_TASK);
    // 返答メッセージ
    let message = "";
    switch (shoriKbn) {
      case SHORI_KBN_INS:
        allMsg.shift();
        message = dataAdd(taskSheet, allMsg);
        break;
      case SHORI_KBN_DEL:
        allMsg.shift();
        message = deleteRow(taskSheet, allMsg);
        break;
      case SHORI_KBN_GET:
        message = returnData(taskSheet);
        break;
      default:
        message = "メッセージマスタに登録されていない文字が送信されたので処理が出来ませんでした。";
        break;
    }

    // lineで返答する
    lineReply(message, replyToken);

  } catch (e) {
    let message = "エラーが発生しました。" + e;
    lineReply(message, replyToken);
  }

}

parts.gs
parts.gs
/**
 * 処理区分取得処理
 * @param msgSheet メッセージシート
 * @param inputMsg 入力メッセージ
 * @return 処理区分
 */
function shoriKbnGet(msgSheet, inputMsg) {

  const msgObjects = dataGet(msgSheet);
  //メッセージマスタと入力メッセージを比較し、処理区分を決定する。
  for (const msg of msgObjects) {
    if (inputMsg.indexOf(msg.message) != -1) {
      return msg.shorikbn;
    }
  }

  return "";
}

/**
 * バリデーションチェック処理
 * @param taskSheet シート
 * @param lastColumn 最終列
 * @param allMsg インプットメッセージ
 * @return エラーメッセージ
 */
function validationCheck(taskSheet, lastColumn, allMsg) {
  //メッセージの形式チェック
  if (allMsg.length !== lastColumn) {
    return `以下の形式で送信して下さい。${NEW_LINE}${NEW_LINE}登録 登録して 等${NEW_LINE}タスク名${NEW_LINE}完了期限(yyyyMMdd)`;
  }

  // タスク名チェック
  const taskName = allMsg[0];
  const dataObjects = dataGet(taskSheet);
  for (const dataObj of dataObjects) {
    if (dataObj.taskname === taskName) {
      return "すでに登録されているタスクです。";
    }
  }

  // 完了期限チェック
  const timeLimit = allMsg[1];
  if (!timeLimit.match("^[0-9]{8}$")) {
    return "期限はyyyyMMdd形式で指定してください。"
  }

  const y = timeLimit.substr(0, 4);
  const m = timeLimit.substr(4, 2);
  const d = timeLimit.substr(6, 2);
  const date = new Date(y, m - 1, d);
  if (m != date.getMonth() + 1) {
    return "無効な日付です。"
  }

  return "";
}

/**
 * データ登録処理
 * @param taskSheet シート
 * @param allMsg インプットメッセージ
 * @return 正常終了メッセージ or エラーメッセージ
 */
function dataAdd(taskSheet, allMsg) {
  // 最終列の取得
  const lastColumn = taskSheet.getLastColumn();
  //受信メッセージが正しい形式か確認
  const errorMsg = validationCheck(taskSheet, lastColumn, allMsg);
  if (errorMsg) {
    return errorMsg;
  }
  // タスクを書き込む行
  const newRow = taskSheet.getLastRow() + 1;
  // タスクを書き込む
  allMsg.forEach((msg, i) => taskSheet.getRange(newRow, i + 1).setValue(msg));
  // 完了期限順でソートしておく
  taskSheet.getRange(START_ROW, START_COLUMN, newRow, lastColumn).sort(COLUMN_TIMELIMIT);
  return "データを登録しました。";
}

/**
 * データ返却処理
 * @param taskSheet シート
 * @return reTaskArray リプライ用タスクリスト
 */
function returnData(taskSheet) {
  // データがあるか判定
  if (taskSheet.getLastRow() == 1) {
    return "タスクがありません。";
  }

  // タスクを全て取得し、返却用メッセージに編集する。
  let reTaskArray = `現在登録されているタスクです。${NEW_LINE}`;
  const dataObjects = dataGet(taskSheet);
  dataObjects.forEach(dataObject => {
    // タスクごとに改行を入れる。
    reTaskArray += NEW_LINE;
    Object.keys(dataObject).forEach(key => reTaskArray += `${key}:${dataObject[key]}${NEW_LINE}`);
  });
  return reTaskArray;
}

/**
 * データ削除処理
 * @param taskSheet シート
 * @param allMsg インプットメッセージ
 * @return 正常終了メッセージ or エラーメッセージ
 */
function deleteRow(taskSheet, allMsg) {
  // 最終行の取得
  const lastRow = taskSheet.getLastRow();
  // 削除対象キー
  const key = allMsg[0];
  // 1行目から順にタスク名を比較し、一致していたら削除する。
  for (let i = START_ROW; i <= lastRow; i++) {
    const taskName = taskSheet.getRange(i, 1).getValue();
    if (key === taskName) {
      taskSheet.deleteRow(i);
      return "完了タスクを削除しました。";
    }
  }

  return "入力されたタスクがありません。"
}

/**
 * トリガー設定処理
 */
function setTrigger() {
  const date = new Date();
  // 9時にトリガーを設定する
  date.setHours(9);
  date.setMinutes(0);
  date.setSeconds(0);
  ScriptApp.newTrigger('remind').timeBased().at(date).create();
}

/**
 * リマインド処理
 */
function remind() {
  // データを書き込むスプレッドシートを定義
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_TASK);
  // スプレッドシートからタスクを取得
  const dataObjects = dataGet(sheet);
  // 現在日時の取得
  const keyDate = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyyMMdd");
  let message = "";
  // 現在日時とtimelimitが一致するデータをリマインドする。
  dataObjects.forEach(dataObject => {
    if (dataObject.timelimit == keyDate) {
      message += NEW_LINE;
      Object.keys(dataObject).forEach(key => message += `${key}:${dataObject[key]}${NEW_LINE}`);
    }
  });
  // リマインドするタスクが無い場合は処理しない。
  if (!message) {
    return;
  }
  push(`今日期限のタスクです。${NEW_LINE}${message}`);
}

/**
 * データ取得処理
 * マスタデータをオブジェクト化して返却する
 * 
 * @param sheet シート
 * @return dataObjects データオブジェクト
 */
function dataGet(sheet) {

  const dataArray = sheet.getRange(HEADER_ROW, START_COLUMN, sheet.getLastRow(), sheet.getLastColumn()).getValues();
  const header = dataArray.shift();
  const dataObjects = [];
  dataArray.forEach(dataRow => {
    const dataObj = {};
    dataRow.forEach((data, i) => {
      dataObj[header[i]] = data;
    });
    dataObjects.push(dataObj);
  });

  return dataObjects;
}

/**
 * メッセージ送信処理
 * @param message メッセージ
 */
function push(message) {

  //プロパティを取得
  const scriptProperties = PropertiesService.getScriptProperties();
  const LINE_TOKEN = scriptProperties.getProperty("LINE_TOKEN");
  const USER_ID = scriptProperties.getProperty("USER_ID");
  const LINE_PUSH_URL = scriptProperties.getProperty("LINE_PUSH_URL");

  const headers = {
    "Content-Type": "application/json; charset=UTF-8",
    "Authorization": `Bearer ${LINE_TOKEN}`
  };

  const postData = {
    "to": USER_ID,
    "messages": [
      {
        'type': 'text',
        'text': message,
      }
    ]
  };

  const options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(postData)
  };

  // lineで応答する
  UrlFetchApp.fetch(LINE_PUSH_URL, options);
}

/**
 * メッセージ応答処理
 * @param message メッセージ
 * @param replyToken リプライトークン
 */
function lineReply(message, replyToken) {

  //プロパティを取得
  const scriptProperties = PropertiesService.getScriptProperties();
  const LINE_TOKEN = scriptProperties.getProperty("LINE_TOKEN");
  const LINE_REPLY_URL = scriptProperties.getProperty("LINE_REPLY_URL");

  const headers = {
    "Content-Type": 'application/json; charset=UTF-8',
    "Authorization": `Bearer ${LINE_TOKEN}`
  };

  const postData = {
    "replyToken": replyToken,
    "messages": [
      {
        "type": "text",
        "text": message
      }
    ]
  };

  const options = {
    "method": "post",
    "headers": headers,
    "payload": JSON.stringify(postData)
  };

  //lineで返答する
  UrlFetchApp.fetch(LINE_REPLY_URL, options);

}
variable.gs
variable.gs
/**改行コード */
const NEW_LINE = "\n";
/**シート名_タスク */
const SHEET_TASK = "タスク";
/**シート名_メッセージ */
const SHEET_MESSAGE = "メッセージ";
/**処理区分_登録 */
const SHORI_KBN_INS = "1";
/**処理区分_削除 */
const SHORI_KBN_DEL = "2";
/**処理区分_照会 */
const SHORI_KBN_GET = "3";
/**ヘッダー行番号 */
const HEADER_ROW = 1;
/**データのスタート行番号 */
const START_ROW = 2;
/**データのスタート列番号 */
const START_COLUMN = 1;
/**カラム_完了期限 */
const COLUMN_TIMELIMIT = 2;

##スクリプトプロパティの登録
コードの中で、スクリプトプロパティにアクセスしている箇所がいくつかあります。
例えば、下記のようにトークンを使用する際などです。

parts.gs
// ~ 略 ~
/**
 * メッセージ送信処理
 * @param message メッセージ
 */
function push(message) {

  //プロパティを取得
  const scriptProperties = PropertiesService.getScriptProperties();
  const LINE_TOKEN = scriptProperties.getProperty("LINE_TOKEN");
  const USER_ID = scriptProperties.getProperty("USER_ID");
  const LINE_PUSH_URL = scriptProperties.getProperty("LINE_PUSH_URL");

// ~ 略 ~

これらは事前に設定する必要があります。
下記コードを実行して下さい。

setPropertie.gs

function setPropertie() {
  //スクリプトプロパティを取得。
  const scriptProperties = PropertiesService.getScriptProperties();

  scriptProperties.setProperties({
    'LINE_TOKEN': 'Messaging Apiの作成にて発行したアクセストークン',
    'LINE_REPLY_URL': 'https://api.line.me/v2/bot/message/reply',
    'LINE_PUSH_URL': 'https://api.line.me/v2/bot/message/push',
    'USER_ID': 'この後の説明参照'
  });
}

USER_IDについては、Messaging Apiで確認可能です。
チャネル基本設定タブへ行き、
スクリーンショット 2021-11-09 16.34.48.png

下の方に行くと、あなたのユーザーIDというのがあります。これをUSER_IDとして登録して下さい。
スクリーンショット 2021-11-09 16.49.03.png

##動作確認
まずはタスクを登録してみます。
登録時のメッセージの送信形式は

処理名(登録、登録してなど)
タスク名
完了期限

です。

IMG_1935.png

入力して送信すると、
IMG_D14CA8AD0043-1.jpg

タスクシートに登録されます。
スクリーンショット 2021-11-10 15.31.39.png

一応日付や形式のチェックとかはしています。
例えば存在しない日付を入力すると、
登録失敗.png
エラーになります。
日付エラー.png

メッセージマスタに登録されている単語であれば、処理をしてくれます。
例えば、「追加して」と言ってみます。

登録2.png

登録成功2.png

登録されました!
登録は完了期限の昇順でソートされます。
スクリーンショット 2021-11-10 16.04.54.png

次は登録されているタスクの確認をしてみます。
「今のタスクは?」や「照会して」などと聞くと、教えてくれます。
照会.png

完了したタスクは削除してしまいましょう。
「削除して」や「完了」と入れて2行目にタスク名を入力すると、削除されます。
削除.png
スプレッドシートからも削除されています!
スクリーンショット 2021-11-10 16.34.00.png

完了期限当日になってもタスクが残っている場合は、9:00にリマインドしてくれます。
リマインド.PNG

##プログラム説明
簡単に処理の流れを解説します。

###処理区分の判定

まず、main.gsのこの部分で処理を判定します。

main.gs
// ~ 略 ~
    // 処理区分の取得
    const shoriKbn = shoriKbnGet(spreadSheet.getSheetByName(SHEET_MESSAGE), allMsg[0]);
    // データを書き込むスプレッドシートを定義
    const taskSheet = spreadSheet.getSheetByName(SHEET_TASK);
    // 返答メッセージ
    let message = "";
    switch (shoriKbn) {
      case SHORI_KBN_INS:
        allMsg.shift();
        message = dataAdd(taskSheet, allMsg);
        break;
      case SHORI_KBN_DEL:
        allMsg.shift();
        message = deleteRow(taskSheet, allMsg);
        break;
      case SHORI_KBN_GET:
        message = returnData(taskSheet);
        break;
      default:
        message = "メッセージマスタに登録されていない文字が送信されたので処理が出来ませんでした。";
        break;
    }
// ~ 略 ~

処理区分の取得でshoriKbnGetという関数を呼び出します。
shoriKbnGetはparts.gsに定義しています。

parts.gs
/**
 * 処理区分取得処理
 * @param msgSheet メッセージシート
 * @param inputMsg 入力メッセージ
 * @return 処理区分
 */
function shoriKbnGet(msgSheet, inputMsg) {

  const msgObjects = dataGet(msgSheet);
  //メッセージマスタと入力メッセージを比較し、処理区分を決定する。
  for (const msg of msgObjects) {
    if (inputMsg.indexOf(msg.message) != -1) {
      return msg.shorikbn;
    }
  }

  return "";
}

// ~ 略 ~

shoriKbnGet関数で送信されたメッセージをキーにメッセージマスタを取得し、それに紐づく処理区分を返却しています。

###タスクの登録
処理区分が"1"(登録)の場合、タスクの登録処理を行います。
先ほどのmain.gsのswitch文でdataAdd関数を呼び出します。

parts.gs

// ~ 略 ~

/**
 * データ登録処理
 * @param taskSheet シート
 * @param allMsg インプットメッセージ
 * @return 正常終了メッセージ or エラーメッセージ
 */
function dataAdd(taskSheet, allMsg) {
  // 最終列の取得
  const lastColumn = taskSheet.getLastColumn();
  //受信メッセージが正しい形式か確認
  const errorMsg = validationCheck(taskSheet, lastColumn, allMsg);
  if (errorMsg) {
    return errorMsg;
  }
  // タスクを書き込む行
  const newRow = taskSheet.getLastRow() + 1;
  // タスクを書き込む
  allMsg.forEach((msg, i) => taskSheet.getRange(newRow, i + 1).setValue(msg));
  // 完了期限順でソートしておく
  taskSheet.getRange(START_ROW, START_COLUMN, newRow, lastColumn).sort(COLUMN_TIMELIMIT);
  return "データを登録しました。";
}


// ~ 略 ~

ここでは、下記の処理を実施しています。

  1. 入力メッセージのバリデーションチェック
  2. 現在登録されているデータの最終行を取得し、次の行に新しいタスクを書き込む
  3. 完了期限順にソートして、登録完了のメッセージを返却

###タスクの削除
処理区分が"2"(削除)の場合、deleteRow関数を呼び出し、タスクの削除を行います。

parts.gs

// ~ 略 ~

/**
 * データ削除処理
 * @param taskSheet シート
 * @param allMsg インプットメッセージ
 * @return 正常終了メッセージ or エラーメッセージ
 */
function deleteRow(taskSheet, allMsg) {
  // 最終行の取得
  const lastRow = taskSheet.getLastRow();
  // 削除対象キー
  const key = allMsg[0];
  // 1行目から順にタスク名を比較し、一致していたら削除する。
  for (let i = START_ROW; i <= lastRow; i++) {
    const taskName = taskSheet.getRange(i, 1).getValue();
    if (key === taskName) {
      taskSheet.deleteRow(i);
      return "完了タスクを削除しました。";
    }
  }

  return "入力されたタスクがありません。"
}


// ~ 略 ~

ここでは、下記の処理を実施しています。

  1. データの最終行までループ
  2. 入力メッセージをキーとし、一致するものがあれば行を削除し、完了メッセージを返却する。
  3. タスクがない場合は、その旨をメッセージとして返却する。

###タスクの照会
処理区分が"3"(照会)の場合、現在登録されているタスクを取得し、メッセージとして返却します。

parts.gs

// ~ 略 ~

/**
 * データ返却処理
 * @param taskSheet シート
 * @return reTaskArray リプライ用タスクリスト
 */
function returnData(taskSheet) {
  // データがあるか判定
  if (taskSheet.getLastRow() == 1) {
    return "タスクがありません。";
  }

  // タスクを全て取得し、返却用メッセージに編集する。
  let reTaskArray = `現在登録されているタスクです。${NEW_LINE}`;
  const dataObjects = dataGet(taskSheet);
  dataObjects.forEach(dataObject => {
    // タスクごとに改行を入れる。
    reTaskArray += NEW_LINE;
    Object.keys(dataObject).forEach(key => reTaskArray += `${key}:${dataObject[key]}${NEW_LINE}`);
  });
  return reTaskArray;
}


// ~ 略 ~

ここでは、下記の処理を実施しています。

  1. データが存在しない(ヘッダーのみ)の場合は、その旨をメッセージとして返却する。
  2. dataGet関数を呼び出し、登録されているタスクをオブジェクトで取得。
  3. キー:バリューの形式でメッセージとしてタスクを返却する。

2では下記の関数を呼び出しています。

parts.gs

// ~ 略 ~

/**
 * データ取得処理
 * マスタデータをオブジェクト化して返却する
 * 
 * @param sheet シート
 * @return dataObjects データオブジェクト
 */
function dataGet(sheet) {

  const dataArray = sheet.getRange(HEADER_ROW, START_COLUMN, sheet.getLastRow(), sheet.getLastColumn()).getValues();
  const header = dataArray.shift();
  const dataObjects = [];
  dataArray.forEach(dataRow => {
    const dataObj = {};
    dataRow.forEach((data, i) => {
      dataObj[header[i]] = data;
    });
    dataObjects.push(dataObj);
  });

  return dataObjects;
}

// ~ 略 ~

この関数はメッセージマスタの参照など、呼び出している箇所がいくつかあります。
データはgetRangeで二次元配列として取得することもできるのですが、参照の際に数字で指定することになり微妙だったのでこんな実装となっています。

###リマインド処理

毎日9:00にremind関数を呼び出します。

parts.gs

// ~ 略 ~

/**
 * リマインド処理
 */
function remind() {
  // データを読み込むスプレッドシートを定義
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_TASK);
  // スプレッドシートからタスクを取得
  const dataObjects = dataGet(sheet);
  // 現在日時の取得
  const keyDate = Utilities.formatDate(new Date(), "Asia/Tokyo", "yyyyMMdd");
  let message = "";
  // 現在日時とtimelimitが一致するデータをリマインドする。
  dataObjects.forEach(dataObject => {
    if (dataObject.timelimit == keyDate) {
      message += NEW_LINE;
      Object.keys(dataObject).forEach(key => message += `${key}:${dataObject[key]}${NEW_LINE}`);
    }
  });
  // リマインドするタスクが無い場合は処理しない。
  if (!message) {
    return;
  }
  push(`今日期限のタスクです。${NEW_LINE}${message}`);
}

// ~ 略 ~

ここでは、下記の処理を実施しています。

  1. 登録されているタスクを取得する
  2. 現在日時と完了期限を比較し、一致しているタスクをリマインド対象とし返却する。
  3. 一致するタスクがない場合は、メッセージを返却せず処理を終了する。

remind関数を毎日9:00に実行する為にトリガーとして登録する必要があります。

そこで、gasのトリガー機能を使用するのですがピッタリ9:00に設定することが出来ません、、
こんな感じで、起動時刻に幅があります。
スクリーンショット 2021-11-14 15.04.24.png

ピッタリの時間にトリガー設定したい場合はコードでやる必要があるようです。
ということで、9:00にトリガーを設定する関数を実装し、その関数を9:00より前に実行するようにしましょう!

スクリプトエディタより、トリガーを選択します。
スクリーンショット 2021-11-14 14.49.23.png

トリガーの追加をポチ
スクリーンショット 2021-11-14 14.51.37.png

下記のように設定します。
スクリーンショット 2021-11-14 14.52.59.png
スクリーンショット 2021-11-14 14.53.46.png

実行する関数setTriggerはこんな感じの実装になっています。

parts.gs

// ~ 略 ~

/**
 * トリガー設定処理
 */
function setTrigger() {
  const date = new Date();
  // 9時にトリガーを設定する
  date.setHours(9);
  date.setMinutes(0);
  date.setSeconds(0);
  ScriptApp.newTrigger('remind').timeBased().at(date).create();
}

// ~ 略 ~

今回は9:00と決め打ちですが、スプレッドシートに設定ファイルシートみたいなの作ってそこから取得とかしても良いかもしれません。

##まとめ
今回、初めてAPI連携を用いた開発だったのですが、他にも色々応用して作れそうだなと思いgasの手軽さに感動しました!
スプレッドシートの操作をコードで書いた時の可読性についても改めて考えたいと思いました。

8
3
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
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?