
こんにちは! くのーるです!
この記事では、Google Apps Script (GAS) をつかった、チャットツール Discord 用のリマインダー bot をつくるレシピをご紹介いたします。
フロント・ワークスでも 大活躍しているおすすめ bot、ぜひチェックしてみてください。
それではレッツゴー! 🚀
リマインダーとは
あらかじめ設定した日時にメッセージを飛ばして、スケジュールを通知してくれるサービスのことです。
仕事のうっかり忘れを解決!
みなさんはお仕事のなかで
「毎月 ◯ 日の営業日」に棚卸し!
「月末の営業日」に取引先へ月次報告書を提出!
「毎年 ◯ 月 ◯ 日」に契約サービスの更新手続き!
など周期的におこなわなくてはならない、さまざまなタスクに日々追われてはいないでしょうか。
またこういったタスクが増えていくと、どうしても……
「しまったー!!! この仕事やるの忘れてたー!!!」
なんてミスが起こることも珍しくはないと思います。そこで、おすすめの解決策が リマインダー bot によるタスク通知の仕組み です。
たとえば、 「月末の営業日にセキュリティ チェック作業をおこなう」 というタスクがあった場合、当日になると Discord の特定チャンネルに対して、bot が以下のような通知を 自動的に送信してくれます。

これにより自身に関わるあらゆるスケジュールは、すべて bot が教えてくれる 仕組みになっています。
スケジュールの通知を bot に任せることで 仕事のうっかり忘れを防ぎ、目の前にある仕事に集中できるようになります。
自分だけのコンシェルジュ サービス みたいなイメージですね!
この記事では Discord 経由でのリマインダー通知方法を紹介していますが、他にも Slack, LINE WORKS, ChatWork など Webhook API サービスが使えるチャット サービスであれば同様の仕組みで通知可能です。
GAS x Discord のメリット
仕事のうっかり忘れを対策をする場合、たとえば メモを取る という方法や、Google カレンダーなどのツールにある リマインド機能を使う といった手段もあります。
しかし GAS と Discord によるリマインダー bot では、他の方法にはない大きなメリットがあります。
Google スプレッドシートによるタスクの管理・棚卸し
リマインドの元となる日時情報・メッセージ内容のデータは Google スプレッドシートにあらかじめ登録をおこないます。

このスプレッドシートは、データベース的な役割を担うため、これ自体が タスクの管理・棚卸し の機能を持ちます。
くわえてスプレッドシートは、組織で共有が可能 です。
従来のタスク管理手段の多くは、個人に委ねられることが多く属人化しやすい傾向にありますが、この管理方法では 個ではなく組織にタスクが紐づく、そしてタスクが可視化される ことが大きなメリットになっています。
日付関数による営業日の自動算出・更新
リマインダーを設定するには、あらかじめ通知メッセージの送信日時を予約しておく必要があります。
たとえば「毎月 ◯ 日の営業日 (土日祝は前倒し)」といったサイクルをもつ定期的な予定タスクがある場合、月ごとに一つずつ予約しなくてはならないため、効率が悪く、メンテナンスが面倒です。
しかし Google スプレッドシートでは 営業日を自動算出・更新する日付関連の関数 を使うことができます。
たとえば以下のような日付関数を先に仕込むと 「毎月 15 日の営業日 (土日祝は前倒し)」 を自動算出し、今日の月を基準に自動更新されるため、手動予約が不要になります。

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

Discord による通知
Discord のチャンネルに送信されるリマインダーは、そのチャンネルに属する メンバー全体に共有されます。
そのためチーム全体に関わるタスクのリマインドにも対応が可能です。
全体にタスクが共有されることは属人化を防ぐうえ、ダブルチェックの効果もありますので、まさに 理想的な通知システム といえます。
GAS x Discord のレシピ
それでは実際に、リマインダー bot を作成していきたいと思います! 👨🍳
材料
- Google アカウント
- Discord アカウント
- 自身が管理者である Discord サーバー
Discord
Discord にてウェブフック URL を取得します。
[ユーザー設定] > [詳細設定] > [開発者モード] を有効にします。


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

この時点では「プライベートチャンネル」として作成し、完成後に公開します。
STEP 2 で作成したチャンネルの設定画面より以下の手順でウェブフック(bot) を作成します。


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

Google スプレッドシート
Google スプレッドシートでタスク リストを作成します。
新しいスプレッドシートを作成し、必要に応じて共有設定をおこないます。
リマインダーの送信先となる Discord のチャンネル リストを作成します。チャンネルに紐づくウェブフック URL は、先ほど Discord 上でコピーしたものを貼り付けます。

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

定期的なタスクの 日付
に関しては、たとえば以下のような日付関連の関数を仕込んでおくことで、日付情報が自動的に更新されます。
=WORKDAY(EOMONTH(TODAY(), - 1) + 1 + 15, - 1, '祝日リスト'!B2:B) // 毎月 15 日の営業日 (土日祝前倒し)
=WORKDAY(TODAY() - WEEKDAY(TODAY()) + 6 + 1, -1, '祝日リスト'!B2:B) // 毎週金曜日の営業日 (土日祝前倒し)

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


日付関数を仕込むことは必須ではなく、決め打ちで日付を入力し、一回限りの単発タスクとして動作させることもできます。
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 リンク
まとめ
今回は GAS をつかった Discord リマインドー bot の作り方をご紹介しました!
フロント・ワークスでは、実際にこのリマインダー システムをつかった タスクの棚卸し・管理・通知 をおこなっています。
ユーザー側としては、定期的なタスクが発生した段階で、以下のタスク管理シートを更新するだけなので、使い勝手がとても良い ものになっています。

予約された日時になると Discord 経由でスケジュールをリマインドしてくれるため、社内では 仕事のうっかり忘れが激減しました。
このリマインダー システムに限らず、仕事の内容を 可視化する・属人化しない・自動化する という三つの要素はとても重要です。
未来の自分、そしてチームがいかに楽できるか を思い描きながら、日々のお仕事に GAS を役立ていきましょう! 🚀