Google Apps Scriptでポモドーロ用のツールを実装した話

本稿はJCB Advent Calendar 2023の12月8日の記事です。

はじめに

DXテックG Platformチームの四方と申します。

本記事では、Google Apps Script(以下、GAS) でアプリを自作した際の学びについて記載していきます。
※ 記事内に記載するコードはTypescript を用いて実装しておりますが、記載の都合上、一部の型定義やエラーハンドリングは省略させていただいております。

背景

所属するチームにてポモドーロテクニックという時間管理術を用いて作業タスクの効率化を図る機会がありました。
その際に時間管理をGASの自動処理に一任し、ChatOpsに組み込むことでタイムキーパーの負荷を軽減することを目標に本機能の実装を開始しました。

ポモドーロテクニックの概要

ポモドーロテクニックについては、Wikipediaの内容をベースとしました。

ポモドーロテクニックでは、作業者は以下のフローに沿ってタスクに着手いたします。

  1. 作業者は以下の時間を設定
    • 作業時間
    • 作業後に取得する短い休憩時間
    • 作業を繰り返す回数
    • 全作業を終えた後に取得する長い休憩時間
  2. 設定した作業時間の間はタスクに集中
  3. 作業後、短い休憩を取得
  4. 作業と短い休憩を一定回数繰り返す
  5. 全ての作業を終えた段階で長い休憩を取得

その上で今回実装するアプリケーションは以下のフローになるよう設計しました。

  1. タスクに集中する作業時間、短い休憩時間、長い休憩時間、繰り返す回数を設定
  2. 作業時間に応じたタイマーを設定
  3. 作業時間経過後、作業を止めて短い休憩を取得するよう作業者に通知
  4. 短い休憩時間に応じたタイマーを設定
  5. 短い休憩時間経過後、作業を再開するよう作業者に通知
  6. 作業と短い休憩を任意の回数分繰り返したら、長い休憩を取得するよう作業者に通知
  7. 長い休憩時間に応じたタイマーを設定
  8. 長い休憩時間経過後、一連のフローが完了した旨を作業者に通知

チャットボットの設定

ポモドーロの流れを、以下のように実現することとします。

  1. Google チャットから/pomodoro コマンド経由で機能を呼び出す
  2. 入力フォームを内包するダイアログを表示し必要な情報をコマンド実行者が入力
    • 作業時間 t[分]
    • 軽い休憩時間 n[分]
    • 長い休憩時間 m[分]
    • 繰り返し回数 x[回]
  3. 作業時間 t[分]が経過したタイミングでルームに通知、軽い休憩 n[分]を取るよう指示
  4. 軽い休憩 n[分]が経過したタイミングでルームに通知、作業時間 t[分]を実施するよう指示
  5. 3 ~ 4を x 回繰り返したら、長い休憩 m[分]を取るよう指示

チャットAPI の設定

GAS でチャットボットを実装するに当たり、紐づいているGCPのプロジェクトにて以下を実施します。

  1. Google Chat API の有効化

  1. Google Chat API の設定にてスラッシュコマンド追加

Pomodoro コマンドの実装

コマンド呼び出しの処理

上記のChat APIで有効化したコマンドはGAS のコード上ではonMessageのイベント経由で取得できます。

onMessageには投稿されたメッセージの情報が引数として渡されており、引数の中身を判定し任意のコマンドを実行します。

以下は先程API上で設定したスラッシュコマンドを判定し、実行するコード例になります。

export function onMessage(event: GoogleChat.Event): GoogleChat.Event.Message | null {
  if (event.message && event.message.slashCommand) {
    // ハンドラクラスのインスタンス化
    const handlers: { [key: number]: OnMessageEventHandler } = {
      1: new PomodoroCommandEventHandler(),
      2: new HogeCommandEventHandler(),
      // ・・・以降、省略(コマンドに対応するクラスを同様に登録)
    };
    // コマンドIDに応じた処理の実行
    if (handlers[event.message.slashCommand.commandId]) {
      try {
        // インスタンスに応じた処理を実行
        return handlers[event.message.slashCommand.commandId].handle(event);
      } catch (e) {
        // エラー処理は省略
      }
    }
    // 対象のスラッシュコマンドが設定されていない場合
    return {
      // エラー処理は省略
    };
  }
  return {
    // エラー処理は省略
  };
}

onMessageにて呼び出されるハンドラの実装がこちらです。
OnMessageEventHandler はhandleメソッドを定義しているインターフェース

export class PomodoroCommandEventHandler implements OnMessageEventHandler {
  public handle(event: GoogleChat.Event): GoogleChat.Event.Message {
    return {
      actionResponse: {
        type: "DIALOG",
        dialogAction: {
          dialog: {
            body: {
              header: {
                title: "Pomodoro timer",
                imageUrl: "https://fonts.gstatic.com/s/e/notoemoji/15.0/1f345/72.png",
                imageType: "SQUARE",
              },
              sections: [
                {
                  widgets: [
                    {
                      textInput: {
                      label: "Work Time(作業時間[分])",
                      type: "SINGLE_LINE",
                      name: "workTime",
                      value: "40",
                      },
                    },
                    {
                      textInput: {
                      label: "Short Break(小休憩[分])",
                      type: "SINGLE_LINE",
                      name: "shortBreak",
                      value: "5",
                      },
                    },
                    {
                      textInput: {
                      label: "Long Break(大休憩[分])",
                      type: "SINGLE_LINE",
                      name: "longBreak",
                      value: "15",
                      },
                    },
                    {
                      textInput: {
                      label: "Repeat Times(繰返し[回])",
                      type: "SINGLE_LINE",
                      name: "repeatTimes",
                      value: "4",
                      },
                    },
                  ],
                },
              ],
              fixedFooter: {
                primaryButton: {
                  text: "Submit",
                  onClick: {
                    action: {
                      // この文字列が後続のイベントにて使用される
                      function: "setPomodoroTimer",
                    },
                  },
                },
              },
            },
          },
        },
      },
    };
  }
}

実際に上記のコマンドを実行すると以下のようなダイアログが表示されます。

  1. コマンドの実行

  1. ダイアログの表示

Submit 押下後の処理

ダイアログのボタンを押下後、GAS ではonCardClickのイベント経由で処理が実行されます。

onCardClickにはダイアログのボタン押下時の情報が引数として渡されており、引数の中身を判定し処理を制御できます。

export function onCardClick(
  event: GoogleChat.Event
): GoogleChat.Event.Message | GoogleChat.Event.Message.ActionResponse | null {
  if (event.action) {
    const handlers: { [key: string]: OnCardClickEventHandler } = {
      // dialog のaction ボタンに紐づいた文字列(function name)で任意のインスタンスと対応させる
      setPomodoroTimer: new SetPomodoroTimer(),
    };
    if (event.action.actionMethodName !== undefined) {
      if (
        handlers[event.action.actionMethodName] !== undefined &&
        handlers[event.action.actionMethodName] !== null
      ) {
        return handlers[event.action.actionMethodName].handle(event);
      }
    }
  }
  return {
    // エラー処理は省略
  };
}

onCardClickにて呼び出されるハンドラーの実装がこちらです。 この処理では、入力値の判定および入力値をプロパティに保管します。

export class SetPomodoroTimer implements OnCardClickEventHandler {
  public handle(event: GoogleChat.Event): GoogleChat.Event.Message {
    // ユーザの入力値を取得
    const formInputs: PomodoroFormInputs = {
      workTime: event.common.formInputs["workTime"][""].stringInputs.value[0],
      shortBreak: event.common.formInputs["shortBreak"][""].stringInputs.value[0],
      longBreak: event.common.formInputs["longBreak"][""].stringInputs.value[0],
      repeatTimes: event.common.formInputs["workTime"][""].stringInputs.value[0],
    };
    // validation を実施
    if (!this.validation(formInputs)) {
      // エラー処理は省略
    }
    // 入力値に従って新規トリガー設定
    return this.setInitialProperty(formInputs, event);
  }

  /**
   * Validation
   */
  public validation(formInputs: PomodoroFormInputs): boolean {
    // validation 処理は省略
  }

  /**
   * ポモドーロテクニック用のプロパティ登録
   */
  public setInitialProperty(formInputs: PomodoroFormInputs, event: GoogleChat.Event): GoogleChat.Event.Message {
    const uuid = Utilities.getUuid();
    const props = PropertiesService.getScriptProperties();
    // Chat Event 情報の取得
    if (
      !event.message ||
      !event.message.thread ||
      !event.message.thread.name ||
      !event.message.space ||
      !event.message.space.name
    ) {
      // エラー処理は省略
    }
    const param: PomodoroProperty = {
      spaceName: event.message.space.name,
      threadName: event.message.thread.name,
      type: "WORK",
      workTime: Number(formInputs.workTime),
      shortBreak: Number(formInputs.shortBreak),
      longBreak: Number(formInputs.longBreak),
      repeatTimes: Number(formInputs.repeatTimes),
    };
    props.setProperty(`TRIGGER_PARAMS_POMODORO_INIT_${uuid}`, JSON.stringify(param));
    const message = `トリガーをセットしました。${formInputs.workTime}分後に通知します。`;

    return this.generateActionResponse("OK", message);
  }

  /**
   * ActionResponse の生成
   */
  public generateActionResponse(
    statusCode: GoogleChat.Event.Message.ActionResponse.DialogAction.ActionStatus.Code,
    message: string
  ): GoogleChat.Event.Message {
    return {
      actionResponse: {
        type: "DIALOG",
        dialogAction: {
          actionStatus: {
            statusCode: statusCode,
            userFacingMessage: message,
          },
        },
      },
    };
  }
}

コマンド実行後の処理

setInitialProperty関数の実行後、プロパティにはTRIGGER_PARAMS_POMODORO_INIT_xxxxxxxxxxxというキーでパラメータが登録されます。

パラメータには以下の情報を格納します。

  1. workTime: 作業時間
  2. shortBreak: 作業と作業の合間の休憩時間
  3. longBreak: 作業終了後の休憩時間
  4. repeatTimes: 作業を繰り返す回数
  5. type: 現在がポモドーロにおけるどのフェーズかを表す文字列
    • 作業中: "WORK"
    • 短い休憩中: "SHORT"
    • 長い休憩中: "LONG"

時間駆動トリガーによるポーリング

setInitialProperty関数によって登録されたTRIGGER_PARAMS_POMODORO_INIT_xxxxxxxxxxxが存在する場合、プロパティの中身に応じた頻度でチャットに通知をしていきます。
そのためにプロパティが存在するかどうかを定期的に確認する時間駆動トリガーを仕込みます。

時間駆動トリガーの処理は以下のようにします。

  1. TRIGGER_PARAMS_POMODORO_INITで始まるプロパティを検知
  2. TRIGGER_PARAMS_POMODORO_INIT_xxxxxxxxxxxに含まれるworkTime(作業時間)を読み取り、その時間が経過したタイミングでチャット通知をする処理を新たに別の時間駆動トリガーでセット
  3. TRIGGER_PARAMS_POMODORO_INIT_xxxxxxxxxxxを削除

本来は、ダイアログへの入力が完了したタイミング(onCardClick)で時間駆動トリガーをセットしようと考えておりました。
しかし、後述のエラーメッセージが表示されポーリングしている時間駆動トリガー経由で別のトリガーをセットする上記の方針としました。

こちらがダイアログから時間駆動トリガーを設定しようとしたときのエラーメッセージになります。
どうやらチャットボット経由での処理では1時間未満の間隔で時間駆動トリガーをセットできないようです。

The recurrence interval for an Add-on trigger must be at least one hour.

チャット通知処理

新規にセットされた時間駆動トリガーの処理は以下になります。
pomodoroTriggerではパラメータ内に格納されたtypeを参照し、作業中、短い休憩中などのフェーズを判断し対応するインスタンスを生成、実行します。

export function pomodoroTrigger(event: GoogleAppsScript.Events.TimeDriven): void {
    // 処理後に自身のトリガーを削除するためにトリガーの一覧を取得
    const triggers = ScriptApp.getProjectTriggers();

    // 引数のかわりにプロパティから情報を取得
    const props = PropertiesService.getScriptProperties();
    const currentTriggerID = event.triggerUid;
    const param = props.getProperty(`TRIGGER_PARAMS_${currentTriggerID}`);
    const parameter = JSON.parse(param) as PomodoroProperty;

    const handlers: { [key: string]: Pomodoro } = {
        WORK: new Pomodoro("WORK", parameter),
        SHORT: new Pomodoro("SHORT", parameter),
        LONG: new Pomodoro("LONG", parameter),
    };
    // type に応じたインスタンスを実行
    if (parameter.type !== undefined) {
        if (handlers[parameter.type] !== undefined && handlers[parameter.type] !== null) {
            handlers[parameter.type].sendMessage();
            handlers[parameter.type].setTrigger();
            handlers[parameter.type].updateProperty();
        }
    }

    // 実行済みのトリガーを削除
    triggers.forEach(trigger => {
        if (trigger.getUniqueId() === currentTriggerID) {
            ScriptApp.deleteTrigger(trigger);
        }
    });
    // 古いparameter を削除
    props.deleteProperty(`TRIGGER_PARAMS_${currentTriggerID}`);
}

Pomodoroインスタンスは渡されたtypeに応じた処理用のインスタンスを生成します。
WORKが渡された場合は下記のインスタンス(WorkTrigger)が生成、実行されます。

継承元のPomodoroTriggerTypeには以下の3つのメソッドが定義されています。

  1. setTrigger
    • 次の時間駆動トリガーをセットするメソッド
  2. updateProperty
    • 次の時間駆動トリガーで使用するパラメータをプロパティに保管するメソッド
  3. sendMessage
    • typeに応じたメッセージをGoogle Chatに通知するメソッド
export class WorkTrigger extends PomodoroTriggerType {
    public setTrigger(pom: Pomodoro): void {
        const property = pom.property;
        const trigger = ScriptApp.newTrigger("pomodoroTrigger")
            .timeBased()
            .after(property.shortBreak * 60 * 1000)
            .create();

        pom.triggerId = trigger.getUniqueId();
    }

    public updateProperty(pom: Pomodoro): void {
        const props = PropertiesService.getScriptProperties();
        const property = pom.property;
        const params: PomodoroProperty = {
            ...property,
            type: "SHORT",
        };
        props.setProperty(`TRIGGER_PARAMS_${pom.triggerId}`, JSON.stringify(params));
    }

    public sendMessage(pom: Pomodoro): void {
        ChatUtils.sendAsyncMessage(
            `作業時間${pom.property.workTime} [分]が経過しました。小休憩に入ってください。`,
            pom.property.spaceName,
            "",
            pom.property.threadKey
        );
    }
}

記載したコードはtypeがWORKの例になりますが、同様にShortTrigger, LongTriggerが存在し、それぞれ3つのメソッドを実行することでタイマーと通知の繰り返しを実現しております。

動作確認

実際に上記のコードを動かしてみました。

実際の出力

ダイアログでセットした時間の間隔で通知が飛んでいることを確認できました。

現状の課題

時間駆動トリガーでポーリングをする都合上、ポーリングの間隔次第ではタイマーの開始が遅れてしまいます。

こちらはポーリングの間隔を狭くすることで緩和できますが、それでも多少のズレが生じてしまいます。 良い解決法が浮かべば、改善したいと思っています。

まとめ

ポモドーロテクニックに使えるツールを作成してみました。

今回の開発では特にGASのトリガーに関する学びがあり、今後の開発にも役立てていけそうです。

最後になりますが、JCBでは我々と一緒に働きたいという人材を募集しています。 詳しい募集要項等については採用ページをご覧下さい。


本文および図表中では、「™」、「®」を明記しておりません。 Google Cloud, GCPならびにすべてのGoogleの商標およびロゴはGoogle LLCの商標または登録商標です。 記載されているロゴ、システム名、製品名は各社及び商標権者の登録商標あるいは商標です。

©JCB Co., Ltd. 20︎21