MENU

【GAS】仕事のうっかり忘れゼロ! スケジュールを通知してくれる Discord リマインダー bot の作り方

  • URLをコピーしました!
gas-discord-reminder_featured-image

こんにちは! くのーるです!

この記事では、Google Apps Script (GAS) をつかった、チャットツール Discord 用のリマインダー bot をつくるレシピをご紹介いたします。

フロント・ワークスでも 大活躍しているおすすめ bot、ぜひチェックしてみてください。

それではレッツゴー! 🚀

リマインダーとは
あらかじめ設定した日時にメッセージを飛ばして、スケジュールを通知してくれるサービスのことです。

目次

仕事のうっかり忘れを解決!

みなさんはお仕事のなかで

「毎月 ◯ 日の営業日」に棚卸し!

「月末の営業日」に取引先へ月次報告書を提出!

「毎年 ◯ 月 ◯ 日」に契約サービスの更新手続き!

など周期的におこなわなくてはならない、さまざまなタスクに日々追われてはいないでしょうか。

またこういったタスクが増えていくと、どうしても……

「しまったー!!! この仕事やるの忘れてたー!!!」


なんてミスが起こることも珍しくはないと思います。そこで、おすすめの解決策が リマインダー bot によるタスク通知の仕組み です。

たとえば、 「月末の営業日にセキュリティ チェック作業をおこなう」 というタスクがあった場合、当日になると Discord の特定チャンネルに対して、bot が以下のような通知を 自動的に送信してくれます。

gas-discord-reminder_01-1
Discord bot メッセージの例

これにより自身に関わるあらゆるスケジュールは、すべて bot が教えてくれる 仕組みになっています。

スケジュールの通知を bot に任せることで 仕事のうっかり忘れを防ぎ、目の前にある仕事に集中できるようになります。

自分だけのコンシェルジュ サービス みたいなイメージですね!

この記事では Discord 経由でのリマインダー通知方法を紹介していますが、他にも Slack, LINE WORKS, ChatWork など Webhook API サービスが使えるチャット サービスであれば同様の仕組みで通知可能です。

GAS x Discord のメリット

仕事のうっかり忘れを対策をする場合、たとえば メモを取る という方法や、Google カレンダーなどのツールにある リマインド機能を使う といった手段もあります。

しかし GAS と Discord によるリマインダー bot では、他の方法にはない大きなメリットがあります。

Google スプレッドシートによるタスクの管理・棚卸し

リマインドの元となる日時情報・メッセージ内容のデータは Google スプレッドシートにあらかじめ登録をおこないます。

gas-discord-reminder_03-1
Google スプレッドシート_タスク リストの例

このスプレッドシートは、データベース的な役割を担うため、これ自体が タスクの管理・棚卸し の機能を持ちます。

くわえてスプレッドシートは、組織で共有が可能 です。

従来のタスク管理手段の多くは、個人に委ねられることが多く属人化しやすい傾向にありますが、この管理方法では 個ではなく組織にタスクが紐づく、そしてタスクが可視化される ことが大きなメリットになっています。

日付関数による営業日の自動算出・更新

リマインダーを設定するには、あらかじめ通知メッセージの送信日時を予約しておく必要があります。

たとえば「毎月 ◯ 日の営業日 (土日祝は前倒し)」といったサイクルをもつ定期的な予定タスクがある場合、月ごとに一つずつ予約しなくてはならないため、効率が悪く、メンテナンスが面倒です。

しかし Google スプレッドシートでは 営業日を自動算出・更新する日付関連の関数 を使うことができます。

たとえば以下のような日付関数を先に仕込むと 「毎月 15 日の営業日 (土日祝は前倒し)」 を自動算出し、今日の月を基準に自動更新されるため、手動予約が不要になります。

gas-discord-reminder_02-2
日付関数の使用例

日付関数の詳しい使用方法については、以下の記事で紹介しています。

Discord による通知

Discord のチャンネルに送信されるリマインダーは、そのチャンネルに属する メンバー全体に共有されます。

そのためチーム全体に関わるタスクのリマインドにも対応が可能です。

全体にタスクが共有されることは属人化を防ぐうえ、ダブルチェックの効果もありますので、まさに 理想的な通知システム といえます。

GAS x Discord のレシピ

それでは実際に、リマインダー bot を作成していきたいと思います! 👨‍🍳

材料

  • Google アカウント
  • Discord アカウント
  • 自身が管理者である Discord サーバー

Discord

Discord にてウェブフック URL を取得します。

STEP
開発者モードの有効化

[ユーザー設定] > [詳細設定] > [開発者モード] を有効にします。

gas-discord-reminder_04-1
gas-discord-reminder_04-3
STEP
チャンネルを作成する

リマインダーの送信先となる Discord テキスト チャンネルを作成します。

gas-discord-reminder_05-1

この時点では「プライベートチャンネル」として作成し、完成後に公開します。

STEP
ウェブフックを作成する

STEP 2 で作成したチャンネルの設定画面より以下の手順でウェブフック(bot) を作成します。

gas-discord-reminder_06-1
gas-discord-reminder_07-2
STEP
ウェブフック URL をコピー

あとで GAS に渡すウェブフック URL をコピーしておきます。

gas-discord-reminder_08-2

Google スプレッドシート

Google スプレッドシートでタスク リストを作成します。

STEP
スプレッドシートの作成

新しいスプレッドシートを作成し、必要に応じて共有設定をおこないます。

STEP
チャンネル リストの作成

リマインダーの送信先となる Discord のチャンネル リストを作成します。チャンネルに紐づくウェブフック URL は、先ほど Discord 上でコピーしたものを貼り付けます。

gas-discord-reminder_09-1
STEP
タスク リストの作成

カラムについては左からスイッチ チャンネル名 グループ 担当者 タイトル メッセージ 日付 時刻 を用意します。

gas-discord-reminder_10-1
STEP
日付関数

定期的なタスクの 日付 に関しては、たとえば以下のような日付関連の関数を仕込んでおくことで、日付情報が自動的に更新されます。

=WORKDAY(EOMONTH(TODAY(), - 1) + 1 + 15, - 1, '祝日リスト'!B2:B) // 毎月 15 日の営業日 (土日祝前倒し)
=WORKDAY(TODAY() - WEEKDAY(TODAY()) + 6 + 1, -1, '祝日リスト'!B2:B) // 毎週金曜日の営業日 (土日祝前倒し)
gas-discord-reminder_11-1
「毎月 15 日の営業日 (土日祝前倒し)」の設定例

なお上記の関数を利用するには、別途祝日情報のリストが必要です。祝日判定を含む日付関数の利用方法について、詳しくは以下の記事で解説しています。

日付関数を仕込むことは必須ではなく、決め打ちで日付を入力し、一回限りの単発タスクとして動作させることもできます。

Google Apps Script (GAS)

Google Apps Scirpt のスクリプト エディターは、セットした gs ファイルを 上から順番に読み込む 仕様です。以下は、スクリプト エディターにセットする順番でコードを紹介しています。

reminder.gs

Discord Bot 経由でリマインダー メッセージを送信するメイン ファンクションです。

/**
 * Discord にメッセージを送信する関数
 */
function runReminderScript(e) {
  const taskSheet = new Sheet(SS.getSheetByName(SHEET_INFO.TASK.NAME));
  const channelSheet = new Sheet(SS.getSheetByName(SHEET_INFO.CHANNEL.NAME));
  const channelDicts = channelSheet.getAsDicts();
  const discordMessage = new DiscordMessage(taskSheet, channelDicts);
  const tte = new TriggerTimeEvents(e);
  const date = tte.getLocaleDate();
  discordMessage.sendAll(date);
}

trigger-setting.gs

runReminderScript を指定の日時に実行するトリガーをセットするためのファンクションです。

/**
 * 翌日のトリガーをセットする関数
 */
function setTrigger() {
  const triggerName = 'runReminderScript';
  const trigger = new Trigger(triggerName);
  const datetime = new Datetime();
  trigger.setTimesForTomorrow(datetime);
}

class/datetime.gs

日時情報に関するクラス「Datetime クラス」を設計します。

以下の処理がおこなえるようメソッドを用意します。

  • 翌日 0:00 の Datetime オブジェクトを取得する
  • タスクがリスト化されているシートをみて、「明日」のタスク分だけを抽出できるよう日時比較をおこなう
  • 指定のフォーマットで日付を文字列化する

このクラスの詳細は、以下の記事で紹介しています。

class Datetime {

  /**
   * 日時に関するコンストラクタ
   * @constructor
   * @param {Date|string|number} param - Date オブジェクトでインスタンス生成可能な引数
   */
  constructor(param = new Date()) {
    /** @type {Date} */
    this.date = new Date(param);
  }

  /**
   * 翌日 0:00 の Datetime オブジェクトを取得するメソッド
   * @return {Datetime} Datetime オブジェクト
   */
  getDtTomorrow() {
    const dtTomorrow = new Datetime(
      this.date.getFullYear(),
      this.date.getMonth(),
      this.date.getDate() + 1
    );
    return dtTomorrow;
  }

  /**
   * format 部分が同じものか比較するメソッド
   * @param {Date} date - 比較対象の Date オブジェクト
   * @param {string} format - 比較するフォーマット
   * @return {boolean} format 部分が同じかどうか
   */
  isSame(date, format = 'yyyy/MM/dd') {
    return Datetime.format(date, format) === Datetime.format(this.date, format);
  }

  /**
   * 指定のフォーマットで日付を文字列化する静的メソッド
   * @param {Date|string} d - Date オブジェクトか日付をあらわす文字列型
   * @param {string} format - フォーマットする形式
   * @return {string} フォーマットされた文字列型の日付
   */
  static format(d = new Date(), format = 'yyyy/MM/dd HH:mm') {
    const date = new Date(d);
    const strDate = Utilities.formatDate(date, 'JST', format);
    return strDate;
  }

}

class/sheet.gs

シートに関するクラス「Sheet クラス」を設計します。

タスク管理シートにある、リマインダー対象となるレコードの取得などを目的としたメソッドを用意しています。

class Sheet {

  /**
   * シートに関するコンストラクタ
   * @constructor
   * @param {SpreadsheetApp.sheet} sheet - 対象となるシート
   * @param {number} headerRows - ヘッダー行の数
   */
  constructor(sheet = SS.getActiveSheet(), headerRows = 1) {
    /** @type {SpreadsheetApp.Sheet} */
    this.sheet = sheet;
    /** @type {number} */
    this.headerRows = headerRows;
  }

  /**
   * シートの値すべて取得するメソッド
   * @return {Array.<Array.<number|string|boolean|Date>>} シートの値
   */
  getDataRangeValues() {
    if (this.dataRangeValues_ !== undefined) return this.dataRangeValues_;
    const dataRangeValues = this.sheet.getDataRange().getValues();
    this.dataRangeValues_ = dataRangeValues;
    return dataRangeValues;
  }

  /**
 * ヘッダー情報 (各) から列インデックスを返すメソッド
 * @param {string} header - ヘッダー
 * @param {number} index - ヘッダーズのヘッダーとなるインデックス
 * @return {number} 列インデックス
 */
  getColumnIndexByHeaderName(header, index = this.headerRows - 1) {
    const headers = this.getHeaders(index);
    const columnIndex = headers.indexOf(header);
    if (columnIndex === -1) throw new Error('There is no value "' + header + '" in the header column.');
    return columnIndex;
  }

  /**
   * ヘッダーを取得するメソッド
   * @param {number} index - ヘッダーズのヘッダーとなるインデックス
   * @return {Array.<number|string|boolean|Date>} ヘッダー
   */
  getHeaders(index = this.headerRows - 1) {
    if (this.headers_ !== undefined) return this.headers_;
    const headerValues = this.getHeaderValues();
    const headers = headerValues[index];
    this.headers_ = headers;
    return headers;
  }

  /**
   * ヘッダー部分を取得するメソッド
   * @return {Array.<Array.<number|string|boolean|Date>>} ヘッダー部分
   */
  getHeaderValues() {
    if (this.headerValues_ !== undefined) return this.headerValues_;
    const values = this.getDataRangeValues();
    const headerValues = values.filter((_, i) => i < this.headerRows);
    this.headerValues_ = headerValues;
    return headerValues;
  }

  /**
   * シートの値から、ヘッダー情報をプロパティとして持つ Map 型を生成するメソッド
   * @param {number} index - ヘッダー行のヘッダーとなるインデックス
   * @return {Array.<Map>} ヘッダー情報を key, 値を value として持つ Map 型
   */
  getAsDicts(index = this.headerRows - 1) {
    if (this.dicts_ !== undefined) return this.dicts_;
    const headers = this.getHeaders(index);
    const values = this.getDataValues();
    const dicts = values.map(record => record.
      reduce((acc, cur, i) => acc.set(headers[i], cur), new Map())
    );
    this.dicts_ = dicts;
    return dicts;
  }

  /**
   * ヘッダー行を除いたレコード部分を取得するメソッド
   * @return {Array.<Array.<number|string|boolean|Date>>} レコード
   */
  getDataValues() {
    if (this.dataValues_ !== undefined) return this.dataValues_;
    const values = this.getDataRangeValues();
    const dataValues = values.filter((_, i) => i >= this.headerRows);
    this.dataValues_ = dataValues;
    return dataValues;
  }

  /**
   * 通知対象のレコード部分を取得するメソッド
   * @param {Date} date - 通知する日時 
   * @return {Array.<Array.<number|string|boolean|Date>>} レコード
   */

  getTargetRecords(date) {
    const values = this.getDataValues();
    const targetRecords = values.filter(record =>
      record[this.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.SWITCH)] === true &&
      Datetime.format(record[this.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.DATE)], 'yyyy/MM/dd ') +
      Datetime.format(record[this.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.TIME)], 'HH:mm') === Datetime.format(date)
    )
    return targetRecords;
  }

}

class/trigger.gs

トリガーに関するクラス「Trigger クラス」を設計します。

以下の処理がおこなえるようメソッドを用意します。

  • すでにセットされているトリガーを一旦すべてクリアする
  • タスク管理シートにある翌日分のタスクの指定日時を取得する
  • 新たに翌日分のトリガーをセットする
class Trigger {

  /**
   * トリガーに関するコンストラクタ
   * @constructor
   * @param {string} functionName - 関数名
   */
  constructor(functionName) {
    /** @type {string} */
    this.functionName = functionName;
  }

  /**
   * 翌日の指定日時のトリガーを設定するメソッド
   * @param {Datetime} datetime - Datetime オブジェクト 
   */
  setTimesForTomorrow(datetime) {
    this.delete();
    const triggerTimes = this.getTimes(datetime);
    if (triggerTimes.length === 0) return;
    triggerTimes.forEach(triggerTime => this.setTimes(triggerTime));
  }

  /**
     * 指定日時のトリガーを設定するメソッド
     * @param {Date} triggerTime - トリガーをセットする指定日時
     */
  setTimes(triggerTime) {
    ScriptApp.newTrigger(this.functionName).
      timeBased().
      at(triggerTime).
      create();
  }

  /**
   * トリガーを設定する時間を取得するメソッド
   * @param {Datetime} datetime - Datetime オブジェクト 
   * @return {Array.<Date>} トリガーを設定する時間
   */
  getTimes(datetime) {
    const dtTomorrow = datetime.getDtTomorrow();
    const sheet = new Sheet(SS.getSheetByName(SHEET_INFO.TASK.NAME));
    const taskValues = sheet.getDataRangeValues();
    const tomorrowTaskValues = taskValues.filter(record => dtTomorrow.isSame(
      new Date(record[sheet.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.DATE)])
    ));
    const triggerTimes = tomorrowTaskValues.
      map(record =>
        Datetime.format(record[sheet.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.DATE)], 'yyyy/MM/dd ') +
        Datetime.format(record[sheet.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.TIME)], 'HH:mm')
      ).
      filter((strTime, i, strTimes) => i === strTimes.indexOf(strTime)).
      map(uniqueStrTime => new Date(uniqueStrTime));
    return triggerTimes;
  }

  /**
   * トリガーを削除するメソッド
   */
  delete() {
    const triggers = ScriptApp.getProjectTriggers();
    triggers.forEach(trigger => {
      if (trigger.getHandlerFunction() !== this.functionName) return;
      ScriptApp.deleteTrigger(trigger);
    });
  }

}

class/trigger-time-event.gs

時間主導型のトリガー イベントに関する「TriggerTimeEvents クラス」を設計します。

本クラスの詳細については、以下の記事で紹介しています。

class TriggerTimeEvents {

  /**
   * 時間主導型のトリガー イベントに関するコンストラクタ
   * @constructor
   * @param {Object} e - 時間主導型のトリガー イベント オブジェクト
   */
  constructor(e) {
    /** @type {number} */
    this.year = e.year;
    /** @type {number} */
    this.month = e.month;
    /** @type {number} */
    this.date = e['day-of-month'];
    /** @type {number} */
    this.hour = e.hour;
    /** @type {number} */
    this.minute = e.minute;
    /** @type {number} */
    this.second = e.second;
    /** @type {string} */
    this.timezone = e.timezone;
  }

  /**
   * 現地時間を取得するメソッド
   * @param {number} diffHours - 時差
   * @return {Date} 時差を調整した日時
   * NOTE: this.timezone が UTC でない場合 (JST であると仮定した) の処理あり
   */
  getLocaleDate(diffHours = 9) {
    if (this.timezone !== 'UTC') return this.getDate();
    const date = this.getDate();
    date.setHours(date.getHours() + diffHours);
    return date;
  }

  /**
   * 時間主導型のトリガーが実行された時間を取得するメソッド
   * @return {Date} 時間主導型のトリガーが実行された日時
   * NOTE: 確認されている状況では UTC の値が設定されている
   */
  getDate() {
    const date = new Date(
      this.year,
      this.month - 1,
      this.date,
      this.hour,
      this.minute,
      this.second
    );
    return date;
  }

}

class/discord-message.gs

Discord メッセージに関するクラス「Discord Message クラス」を設計します。

以下の処理がおこなえるようメソッドを用意します。

  • タスク管理シートで設定されたリマインダー送信先のチャンネル名をもとに Discord Webhook URL を取得する
  • 対象となるレコードを Discord に送信する
class DiscordMessage {

  /**
   * Discord のメッセージ送信に関するコンストラクタ
   * @constructor
   * @param {Sheet} sheet - Sheet オブジェクト
   * @param {Array.<Map>} dicts - チャンネル情報
   */
  constructor(sheet, dicts) {
    /** @type {Sheet} */
    this.sheet = sheet;
    /** @type {Array.<Map>} */
    this.dicts = dicts;
  }

  /**
   * すべての対象レコードのメッセージを送信するメソッド
   * @param {Date} date - Date オブジェクト
   */
  sendAll(date) {
    const records = this.sheet.getTargetRecords(date);
    records.forEach(record => this.send(record));
  }

  /**
   * 対象レコードのメッセージを送信するメソッド
   * @param {Array.<number|string|boolean|Date>} record - 対象レコード
   */
  send(record) {
    const channelName = record[this.sheet.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.CHANNEL_NAME)];
    const webhookUrl = this.getWebhookUrl(channelName);
    const message = record[this.sheet.getColumnIndexByHeaderName(SHEET_INFO.TASK.COLUMN.MESSAGE)];
    const params = this.getParams(message);
    UrlFetchApp.fetch(webhookUrl, params);
  }

  /**
   * チャンネル名から Webhook URL を取得するメソッド
   * @param {string} channelName - チャンネル名
   * @return {string} Webhook URL
   */
  getWebhookUrl(channelName) {
    const dict = this.dicts.find(dict => dict.get(SHEET_INFO.CHANNEL.COLUMN.CHANNEL_NAME) === channelName);
    const webhookUrl = dict.get(SHEET_INFO.CHANNEL.COLUMN.WEBHOOK_URL);
    return webhookUrl;
  }

  /**
   * UrlFetchApp で使用するパラメーターを取得するメソッド
   * @param {string} message - 送信するメッセージ
   * @return {Object} パラメーター
   */
  getParams(message) {
    const params =
    {
      method: 'POST',
      headers: {
        'Content-type': 'application/json'
      },
      payload: JSON.stringify({
        content: message
      })
    };
    return params;
  }

}

global.gs

スクリプト エディターのグローバル領域に記述する内容です。

/**
 * GitHub README.md
 * https://github.com/FrontWorks-Inc/blog_discord-reminder-bot/blob/main/README.md
 */

/**
 * グローバル定数宣言
 */
/** @type {SpreadsheetApp.Spreadsheet} */
const SS = SpreadsheetApp.getActiveSpreadsheet();
/** @enum {string} */
const SHEET_INFO = Object.freeze(
  {
    CHANNEL: {
      NAME: 'Channel',
      COLUMN: {
        CHANNEL_NAME: 'チャンネル名',
        WEBHOOK_URL: 'Webhook URL'
      }
    },
    TASK: {
      NAME: 'Task',
      COLUMN: {
        SWITCH: 'スイッチ',
        CHANNEL_NAME: 'チャンネル名',
        MESSAGE: 'メッセージ',
        DATE: '日付',
        TIME: '時刻'
      }
    }
  }
);

Github リンク

GitHub
GitHub - FrontWorks-Inc/blog_discord-reminder-bot: First Version First Version. Contribute to FrontWorks-Inc/blog_discord-reminder-bot development by creating an account on GitHub.

まとめ

今回は GAS をつかった Discord リマインドー bot の作り方をご紹介しました!

フロント・ワークスでは、実際にこのリマインダー システムをつかった タスクの棚卸し・管理・通知 をおこなっています。

ユーザー側としては、定期的なタスクが発生した段階で、以下のタスク管理シートを更新するだけなので、使い勝手がとても良い ものになっています。

gas-discord-reminder_12-1

予約された日時になると Discord 経由でスケジュールをリマインドしてくれるため、社内では 仕事のうっかり忘れが激減しました。

このリマインダー システムに限らず、仕事の内容を 可視化する・属人化しない・自動化する という三つの要素はとても重要です。

未来の自分、そしてチームがいかに楽できるか を思い描きながら、日々のお仕事に GAS を役立ていきましょう! 🚀

gas-discord-reminder_featured-image

この記事が気に入ったら
フォローしてね!

よかったらシェアしてね!
  • URLをコピーしました!

この記事を書いた人

くのーるです! 下町生まれ、下町育ちの江戸っ子 🗼

フロント・ワークスで取締役情報システム担当役員 & 東京通信大学 (TOU) 情報マネジメント学部で社会人大学生をしています。

この数年は VR 沼にはまり、VR 系コミュニティの運営活動もおこなっています。最近は、テレワークで体重が気になり始めたので、VR フィットネスでダイエット中です💦

🥽 VR Game Japan 運営
👨‍🚀 Echo VR Japan 運営
🥏 VR Master League 初代 Echo Japan (OCE S3 9 位・日本 1 位) 元選手

目次
閉じる