くらめその情シス:ユーザーに対して自動リマインドする仕組みをつくってみた

2022.01.21

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

情シスやってます、アノテーションの髙嶋です。

定期的に行う業務の中で「毎回こういった手間があるけどいい手はないか?」という話がありました。 どんな点が困っていたの?全体的にどうしたの?という内容は下記にまとめられていますので、そちらを先に一読いただければと思います。

【関連記事】

くらめその情シス:PCの棚卸を効率化してみた

本記事ではその業務を改善するため、新たにツール化を行ったのでその中身に関して記載しています。

ツール化により改善した箇所と概要

概要

ツール化したのは関連記事内の"顕著になってきた問題""未回答の後追い"の部分になります。

今回はGoogleフォームのアンケートに回答がない人に対してリマインドするという構成になっており、ざっくりとして処理の流れは下記のようになります。

  1. 回答対象者の一覧を取得する。
  2. 対象者の回答状況を判定する。
  3. 未回答の場合、DMを送る。

で、もし未回答だとこんなメッセージが送られます。

使用技術

今回使用しているのは下記になります。
弊社内ではGoogle WorkspaceとSlackを使用しているので、その環境上で実行できるようにしています。

  • Slack(Slackアプリ込み)
  • Googleスプレッドシート
  • Google Apps Script(TypeScript)

必要な情報

  • リマインド対象とする人のメールアドレス(Slackのアカウントへ設定されているメールアドレス)
  • リマインド対象とする人が回答済みか否か

今回はメールアドレスをもとにDMを送る人を指定できるようにしています。

実装

注意事項

今回記載するプログラムソースにはちょっとした既知の問題があったりもします。 もし参考にする場合、その辺を認識していただき、自己責任でご使用ください。
(セキュリティ的な問題があるとか、そういう類のものではないです)

実行環境構築

実行環境の構築手順です。

  1. Googleスプレッドシートを作成する。
  2. claspを使用して、上記のGoogleスプレッドシートへソースファイルをプッシュする。
  3. Slackアプリを作成する。
  4. SlackアプリのトークンをGoogle Apps Scriptのプロパティへ設定する。

上記を実行すれば、処理を実行できるようになります。
手動で実行する場合はGoogleスプレッドシートの「メニュー」→「棚卸し」→「未回答者DM送信」で実行できますし、自動実行する場合は送りたい条件に合わせてトリガーを作成すればOKです。

また、各内容の詳細は下記に記述します。

Slack

Slackアプリを作成してトークンを発行します。(トークンの発行方法の記述は省略)
今回のツールでは下記Scopeへの許可が必要になります。

chat:write ← DMを送るために必要
users:read ← "users:read.email"を付与するために必要
users:read.email ← メールアドレスをSlackのIDへ変換するために必要

Googleスプレッドシート

下記のような感じで送信対象とする人の一覧を作成します。

自動でリマインドするため、その人が回答済みかどうかは都度最新の状態を把握できる必要があります。
今回はGoogleフォームから回答結果をスプレッドシートへ出力するようになっていたので、数式を使って回答結果のシートにメールアドレスがいるかどうかで判定するようにしています。(L列)

また、「この人は送信対象外にする」ということも手動で選択できるようにしています。(M列)
※単に一覧から削除するでも同じです

Google Apps Script(TypeScript)

ソースファイルの構成はこんな感じになっています。
これをclaspを使用して実行環境にプッシュしています。

./
├ src
   ├ repository
   |  ├ dto
   |  |  └ userAnswerDto.ts
   |  └ userAnswerRepository.ts
   |
   ├ service
   |  └ userAnswereService.ts
   |
   ├ usecase
   |  ├ commands
   |  |  └ unansweredMemberReminderUsecaseCommand.ts
   |  └ unansweredMemberReminderUsecase.ts
   |
   ├ util
   |  └ slack
   |     └ slack.ts
   |
   ├ handler.ts
   └ menu.ts

※上記以外にpackage.jsontsconfig.json 等もありますが記載省略

各ファイルの中身も記述します。 ツールのままのソースだと長くなりすぎるので、今回のメインとなる箇所のみ残すように削っています。
(実際のツールだと、たとえば土日祝は送信しないという判定、などを行っていたりします)

handler.ts

import { UserAnswerRepository, } from '#/repository/userAnswerRepository';
import { UserAnswereService, } from '#/service/userAnswereService';
import { UnansweredMemberReminderUsecase, } from '#/usecase/unansweredMemberReminderUsecase';
import { Slack, } from '#/util/slack/slack';

/**
 * 未回答者へリマインドする。
 */
export function unansweredMemberRemind(): void {

    //プロパティ取得
    const slackToken: string | null = PropertiesService.getScriptProperties().getProperty('SLACK_TOKEN');
    if (slackToken === null) throw new Error('[プロパティ:SLACK_TOKEN]が設定されていません。');

    const slack = new Slack(slackToken);

    const userAnswerRepository = new UserAnswerRepository();

    const service = new UserAnswereService(
        userAnswerRepository,
        slack
    );

    const usecase = new UnansweredMemberReminderUsecase(
        service,
        slack
    );

    usecase.run();
}

処理の起動元となるファイルです。
必要な情報を設定して、処理を実行します。

unansweredMemberReminderUsecase.ts

import { Slack, } from '#/util/slack/slack';
import { UserAnswereService, } from '#/service/userAnswereService';

/**
 * 未回答者リマインドユースケース
 */
export class UnansweredMemberReminderUsecase {

    private remindMessage = `PC棚卸にご協力ください。
下記を確認し回答をお願いします。
https://classmethod.slack.com/archives/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
`

    /**
     * コンストラクタ
     * @param service 
     * @param slack 
     */
    public constructor(
        private readonly service: UserAnswereService,
        private readonly slack: Slack
    ) { }

    /**
     * リマインド処理
     */
    public run(): void {

        //未回答者のSlackID一覧を取得し、DMを送付する。
        const slackUserIdList = this.service.getUnanswerdSlackIds();

        slackUserIdList.forEach(
            slackUserId => {
                this.slack.postMessage(
                    slackUserId,
                    this.remindMessage,
                );
            }
        );
    }
}

メインとなる処理を記述したクラスです。
対象者のSlack IDを取得して、あとは個人個人に定型文を送ります。

userAnswereService.ts

import { UserAnswerRepository, } from '#/repository/userAnswerRepository';
import { Slack, } from '#/util/slack/slack';

/**
 * 
 */
export class UserAnswereService {

    /**
     * コンストラクタ
     * @param userAnswerRepository 
     * @param slack 
     */
    public constructor(
        private readonly userAnswerRepository: UserAnswerRepository,
        private readonly slack: Slack
    ) { }

    /**
     * 
     * @returns 
     */
    public getUnanswerdSlackIds(): string[] {

        //ユーザーからの回答状況を取得する
        const userAnswerList = this.userAnswerRepository.getAnswerStatus();

        //未回答ユーザーのメールアドレスを取得する
        const mailAddressList = userAnswerList.filter(
            userAnswer => {
                return userAnswer.isInvitationTarget();
            }
        ).map(
            userAnswer => {
                return userAnswer.mailAddress;
            }
        );

        //メールアドレスからSlackIDへ変換する。
        const slackUserIdList: string[] = [];

        mailAddressList.forEach(
            mailAddress => {

                try {
                    const id = this.slack.getUserInfo(mailAddress).id;
                    slackUserIdList.push(id);
                } catch (e) {
                    console.warn(e);
                }
            }
        );

        return slackUserIdList;
    }
}

一覧を取得し、その中からリマインド対象とする人を判定して、該当者のSlack IDを取得するクラスです。

userAnswerDto.ts

/**
 * ユーザー回答クラス
 */
export class UserAnswerDto {

    /**
     * コンストラクタ
     * @param mailAddress メールアドレス
     * @param isAnswered 回答済み
     * @param isNoAnswerRequired 回答対象
     */
    public constructor(
        public readonly mailAddress: string,
        private readonly isAnswered: boolean,
        private readonly isNoAnswerRequired: boolean,
    ) { }

    /**
     * 通知対象かどうかを取得する。
     * @param userAnswer 
     * @returns 
     */
    public isInvitationTarget(): boolean {
        return !this.isNoAnswerRequired && !this.isAnswered;
    }
}

スプレッドシートから取得した行単位の情報を保持するためのクラスです。

userAnswerRepository.ts

import { UserAnswerDto, } from '#/repository/dto/userAnswerDto';

/**
 * ユーザー回答状況クラス
 */
export class UserAnswerRepository {

    /**
     * 処理対象シート
     */
    private sheet: GoogleAppsScript.Spreadsheet.Sheet;

    /**
     * データ取得開始行数
     */
    private readonly DATA_START_ROWNUM: number = 3;

    /**
     * データ取得開始列数
     */
    private readonly DATA_GET_COLNUM: number = 1;

    /**
     * データ取得数
     */
    private readonly DATA_GET_SIZE: number = 13;

    /**
     * メールアドレスインデックス
     */
    private readonly USER_MAIL_ADDRESS_IDX: number = 3;

    /**
     * 回答済みインデックス
     */
    private readonly IS_ANSWERED_IDX: number = 11;

    /**
     * 回答不要インデックス
     */
    private readonly IS_NO_ANSWER_REQUIRED_IDX: number = 12;

    /**
     * コンストラクタ
     */
    public constructor() {
        const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('回答状況');

        if (sheet === null) throw new Error('「回答状況」シートが存在しません。');

        this.sheet = sheet;
    }

    /**
     * 
     * @returns 
     */
    public getAnswerStatus(): UserAnswerDto[] {

        const endRow: number = this.sheet.getLastRow();

        if (endRow < this.DATA_START_ROWNUM) return [];

        const records = this.sheet.getRange(
            this.DATA_START_ROWNUM,
            this.DATA_GET_COLNUM,
            endRow - (this.DATA_START_ROWNUM - 1),
            this.DATA_GET_SIZE
        ).getValues();

        return records.filter(
            values => {
                return values[this.USER_MAIL_ADDRESS_IDX] !== '';
            }
        ).map(
            values => {
                return new UserAnswerDto(
                    values[this.USER_MAIL_ADDRESS_IDX],
                    values[this.IS_ANSWERED_IDX],
                    values[this.IS_NO_ANSWER_REQUIRED_IDX],
                );
            }
        );
    }
}

スプレッドシートから対象ユーザーの情報を取得する処理をするクラスです。
"回答状況"というシートからデータを取得します。

slack.ts

/**
 * Slack用API
 */
export class Slack {

    private readonly URL: string = 'https://slack.com/api/';

    private readonly METHOD_POST_MESSAGE: string = 'chat.postMessage';
    private readonly METHOD_LOOKUP_BY_EMAIL: string = 'users.lookupByEmail';

    /**
     * コンストラクタ
     * @param apiToken APIトークン
     */
    public constructor(
        private readonly apiToken: string
    ) { }

    /**
     * メッセージを送信する。
     *
     * @param userId ユーザーID
     * @param plainText 送信メッセージ(平文)
     * @return 送信成否
     */
    public postMessage(
        userId: string,
        plainText: string
    ): boolean {

        const param = `${this.METHOD_POST_MESSAGE}`;
        const payload = {
            'channel': `${userId}`,
            'text': `${plainText}`,
        };

        this.post(
            param,
            payload
        );
        return true;
    }

    /**
     * Slackからメールアドレスに対応するユーザの情報を取得する。
     *
     * @param plainMailAddress ユーザー情報を取得するメールアドレス(平文)
     * @return ユーザー情報
     */
    public getUserInfo(
        plainMailAddress: string
    ): SlackUser {

        const urlEncodeMailAddress = encodeURIComponent(plainMailAddress);
        const param = `${this.METHOD_LOOKUP_BY_EMAIL}?email=${urlEncodeMailAddress}`;

        const response = this.get(param);
        return this.setUserInfo(response);
    }

    /**
     * Slackへメッセージを送信する。
     * @param sendContent 送信内容
     * @returns 
     */
    private get(
        sendContent: string
    ): string {

        const sendUrl = this.URL + sendContent;

        const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
            'headers': this.createHeader(),
            'method': 'get',
        };

        return UrlFetchApp.fetch(sendUrl, options).getContentText();
    }

    /**
     * Slackへメッセージを送信する。
     * @param method 
     * @param payload 
     * @returns 
     */
    private post(
        method: string,
        payload: { [key: string]: string },
    ): string {

        const sendUrl = this.URL + method;

        const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
            'headers': this.createHeader(),
            'method': 'post',
            'contentType': 'application/json;charset=UTF-8',
            'payload': JSON.stringify(payload),
        };

        return UrlFetchApp.fetch(sendUrl, options).getContentText();
    }

    /**
     * 
     * @returns 
     */
    private createHeader(): { [key: string]: string } {
        return {
            'Authorization': 'Bearer ' + this.apiToken,
        };
    }

    /**
     * Slackから取得したユーザー情報を設定する。
     *
     * @param string $slackResponse Slackからの応答内容
     * @return array ユーザー情報(json形式)
     */
    private setUserInfo(
        slackResponseContext: string
    ): SlackUser {

        const jsonVal = JSON.parse(slackResponseContext);

        //取得したユーザーの情報が有効かどうかを判定する。
        if (jsonVal.ok && jsonVal.user.deleted === false) return jsonVal.user;

        throw new Error('指定したユーザーの情報が存在しません。');
    }
}

SlackのAPIを実行するクラスです。 今回は下記の2つを使用しています。

  • chat.postMessage ← DMを送るため
  • users.lookupByEmail ← メールアドレスをもとにSlackのユーザーIDを取得するため

menu.ts

/**
 * メニューバーにカスタムメニューを追加
 */
export function onOpen(): void {
    const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
    const entries = [
        { name: '未回答者DM送信', functionName: 'unansweredMemberRemind', },
    ];
    spreadsheet.addMenu('棚卸し', entries);
}

処理をGoogleスプレッドシートのメニューから実行できるようにするための処理です。
もしトリガーによる自動実行しかしないのであればファイル自体不要です。

課題事項(既知の問題など)

実行してから「あー、そうだよね」と気付いたのですが、このツールで大量の方にDMを一斉送信しようとしたら、Slack APIの実行上限に引っかかってしまいました(汗)
原因はメールアドレス→Slack IDの変換で、都度APIを実行しているからになります。なので、users.lookupByEmailではなく、users.listを使うようにすれば解消できるかな?と思ってます。

あとは、

  • 通知用の文言がソース内にベタで書かれているので修正しづらい
  • 個人ごとに異なる情報を設定した文言で送りたい
  • エラー処理がよろしくない

などなどあったりするので、この辺りは今後の課題として改善したいと思います。

最後に

バグはあったものの、ツール化したことで「自動でリマインドできるようになって楽になった」というコメントをもらいました。 そういったコメントをもらえると「やってよかったー」と思います。(ので、みなさんもそういうのがあったら感謝を伝えましょうw)

ツール化に限らず、今後も「業務を楽にする」という取り組みをしていければと思います。

最後までお読みいただきありがとうございました。 それでは、また次の記事でお会いしましょう。

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社WEBサイトをご覧ください。