iimon TECH BLOG

iimonエンジニアが得られた経験や知識を共有して世の中をイイモンにしていくためのブログです

Slack APIとGASで朝会の抽選コマンドを実装してみた

はじめに

えー、、気がつけば入社から一年が経過してました。

社歴としては2年生、そしてつい先日1.5成人を迎えました、まつむらです。

さて、改めて昨年を振り返ってみると、時間の大切さというものを実感するわけですよ!

やりたいこともあるし、それ以上にやらなきゃいけないこともある。

そんな時はなるべく楽をしたい!そんなお年頃というわけです。

さて、そんな時短の一環として入力チームでは朝会における司会の抽選をしてくれるSlackのカスタムコマンドを用意してます!

抽選を行う仕組み自体は以前からもありましたが、Slackのカスタムレスポンスを利用したもので、メリットとデメリットがありました。

メリット

  • 面倒なプログラムを書かなくても実行できる

デメリット

  • 返せるレスポンスの形式が限られる
  • 特定のワードを打つのに時間がかかる
  • 特定のワードで発火するため、意図せぬタイミングで発火する可能性がある

特にデメリットが大きく、カスタム性および安定性に欠けていました。

というよりかはカスタムレスポンスでやるには限度があった感じですねー……。

そこで、Slackのスラッシュコマンドで実行できるようにしてみました!

とりあえず作ってみた

使うもの

1. Slack Appの設定

1.1. アプリの作成

はじめに、カスタムコマンドを適用するために、Slack Appを作成しておきます。

以下URLよりslackのアプリ一覧へ移動

https://api.slack.com/apps

Create New App > From scratchを選択

create new app > From scratch
Create New App > From scratch

App Nameにアプリ名を入力し、 Pick a workspace to develop your app in:にてワークスペース(所属の組織)を選択

slack設定1
slack設定1

1.2. 設定を控える

アプリが作成されたら次のような画面が表示されるので、以下の2つを控えます。

Slack API設定画面
Slack API設定画面

  • App ID: アプリのID
  • Verification token: Slackのトーク

1.3. botの表示名を設定する

Features > App Home > Your App’s Presence in Slackへ移動し、App Display Nameのeditボタンを押下。

URL直叩き用: https://api.slack.com/apps/ワークスペースID/app-home?

Display Name (Bot Name)にbot名を設定する(日本語名でもOK)。

Default usernameにbotのユーザーとしての名前を設定する(日本語名はNG)。

App Display Name と Default username
App Display Name と Default username

botの表示名変更が完了しました!

1.4. OAuthの設定

最後にOAuthの設定をします。

これをしないとアプリとして追加できませぬ。。

Features > OAuth & Permissions > Scopes > Bot Token Scopes へ移動し、Add an Oauth Scope を押下。

URL直叩き用: https://api.slack.com/apps/ワークスペースID/oauth?

auth scope
auth scope

次の権限を追加。

  • chat:write

botによる書き込みを許可

  • commands

コマンドの実行を許可

追加できたら OAuth Tokens から Install to ワークスペース名を押下。

アプリのインストール
アプリのインストール

表示された画面で連携を許可!

連携を許可
連携を許可

これで設定は完了です!

2. Spreadsheetの作成

Spreadsheetを利用する理由としては、以下の2点があります。

  • メンバー追加時にコード自体を弄るようなことはしたくない。
  • 誰でも簡単(※諸説あり)にメンバーを追加、ステータスを変更できるようにするため。

そして、Spreadsheetには次の項目を設定しました。

  • name: 表示名(ニックネームでもなんでも)
  • slack_user_name: slackのユーザ名
  • slack_user_id: slackのユーザID
  • activate: 長期休暇か否か(産休・育休の社員もいるため)

activateのフラグを持たせることで、簡単に長期休暇等で離脱するメンバーを抽選から外すことができるようになっています!

Spreadsheet
Spreadsheet

さてここで問題になるのがslack_user_nameとslack_user_idをどこで取得するのかということ。

個別にユーザをあたってもいいのですが、一覧で見た方が早そうなのでSlackの設定画面より取得します。

https://ワークスペース名.slack.com/stats#members

ただ、このままだとメンバーリストはデフォルトのため表示されません。

なので、列を編集して表示します。

列を編集するより表示を変更する
列を編集するより表示を変更する

ユーザーIDとユーザー名にチェックを入れる事で表示されるようになります。

これを先ほどのシートに追記します。

3. GASの作成

いよいよ本題のGASことGoogle Apps Scriptを書いていきます!

3.1. GASへ記載するコード

内容としては以下の通りになっています。

/**
 * Slack config
 */
const SLACK_TOKEN = PropertiesService.getScriptProperties().getProperty("SLACK_TOKEN");
const CHANNEL_ID = PropertiesService.getScriptProperties().getProperty("CHANNEL_ID");
const TEAM_RED_ID = PropertiesService.getScriptProperties().getProperty("TEAM_RED_ID");
const TEAM_BLUE_ID = PropertiesService.getScriptProperties().getProperty("TEAM_BLUE_ID");
const TEAM_RED_MEET_ID = PropertiesService.getScriptProperties().getProperty("TEAM_RED_MEET_ID");
const TEAM_BLUE_MEET_ID = PropertiesService.getScriptProperties().getProperty("TEAM_BLUE_MEET_ID");

/**
 * Spreadsheet config
 */
const SHEET_ID = PropertiesService.getScriptProperties().getProperty("SHEET_ID");

/**
 * constant
 */
const MEET_URL = "https://meet.google.com/";
const RED = 'red';
const BLUE = 'blue';
const TEAM_JP = {
  red: ':large_red_square:赤:large_red_square:',
  blue: ':large_blue_square:青:large_blue_square:'
};
const TEAM_IDS = {
  red: TEAM_RED_ID,
  blue: TEAM_BLUE_ID
};
const TEAM_MEET_URLS = {
  red: `${MEET_URL}${TEAM_RED_MEET_ID}`,
  blue: `${MEET_URL}${TEAM_BLUE_MEET_ID}`,
};

/**
 * Error message
 */
const DIFFERENT_CHANNEL = "このコマンドは特定のチャンネルでのみ使用可能です";

const randomPickup = (members) => {
  const max = members.length
  const index = Math.floor(Math.random() * max);
  return members[index];
}

const getMemberFullList = (team, spreadSheet) => {
  const sheet = spreadSheet.getSheetByName(team);
  const values = sheet.getRange('A2:D').getValues();
  const memberList = values
    .map(value => {
      const [name, slackUsername,slackUserId, activate] = value;
      if ('' !== name && '' !== slackUsername && activate) {
        return {
          name,
          slackUsername,
          slackUserId
        }
      }
    }).filter(member => !!member);
  return memberList;
}

const getMemberList = (spreadSheet) => {
  const sheets = [
    spreadSheet.getSheetByName(RED),
    spreadSheet.getSheetByName(BLUE),
  ];
  const [red, blue] = sheets.map(sheet => sheet.getRange('B2:B').getValues().flat().filter(value => !!value));
  return {
    red,
    blue
  }
}

const getTeam = (userName, spreadSheet) => {
  const teams = getMemberList(spreadSheet);
  if (teams.red.includes(userName)) {
    return RED;
  } else if (teams.blue.includes(userName)) {
    return BLUE;
  } else {
    throw new Error('No assigned Member....');
  }
}

const getClerk = (team, spreadSheet) => {
  const list = getMemberFullList(team);
  const member = randomPickup(list);
  return member;
}

function doPost(e) {
  const {token, channel_id, user_name, text} = e.parameter;

  // トークン認証
  if (token !== SLACK_TOKEN) {
    throw new Error('Invalid token!!!!');
  }
  if (channel_id !== CHANNEL_ID) {
    return ContentService
      .createTextOutput(
        JSON.stringify({
          "response_type": "ephemeral",
          "text": DIFFERENT_CHANNEL
        })
      )
      .setMimeType(ContentService.MimeType.JSON);
  }

  // シート取得
  const spreadSheet = SpreadsheetApp.openById(SHEET_ID);
  // チーム取得
  const team = getTeam(user_name, spreadSheet);

  // 抽選
  const member = getClerk(team, spreadSheet);

  // チャンネル投稿
  const outputText = `入力チーム${TEAM_JP[team]}の議事録担当 <@${member.slackUserId}>¥n<!subteam^${TEAM_IDS[team]}> 朝会始めます!`;

  const contentService = ContentService
    .createTextOutput(
      JSON.stringify({
        "response_type": "in_channel",
        "text": outputText
      })
    )
    .setMimeType(ContentService.MimeType.JSON);
  return contentService
}

以下の定数は、GASの設定画面より設定します。

[Slack関連]

  • SLACK_TOKEN: Slack API設定画面にて控えたもの
  • CHANNEL_ID: Slack API設定画面にて控えたもの
  • TEAM_RED_ID: ユーザグループ(赤チーム)のID
  • TEAM_BLUE_ID: ユーザグループ(青チーム)のID

[Meet関連]

  • TEAM_RED_MEET_ID: チーム別のMeet
  • TEAM_BLUE_MEET_ID: チーム別のMeet

[Spreadsheet関連]

  • SHEET_ID: SpreadsheetのID

それでは内容について軽く触れていきます!

3.2. コードの解説

  • randomPickup(members)

membersの中から抽選します。やってること自体は解説不要かなと。

  • getMemberList(spreadSheet)

getTeamにて使用するチーム情報を取得したかったので、slack_user_nameが設定されているB列だけ返すようにしてます。

  • getTeam(user_name, spreadSheet)

実は入力チームは赤と青に分かれているため、どちらのチームに所属しているのかを取得してます。

両チームのシートを取得し、所属するチームを返すようにしてます。

  • getMemberFullList(team, spreadSheet)

name, slack_user_name, slack_user_id, activateを取得して、その中から有効なアカウントのみ抜き出して返します。

  • getClerk(team, spreadSheet)

本日の担当者を取得します。

getMemberFullList で取得したリストを randomPickupに繋げる形になっています。

  • doPost(e)

スラッシュコマンドにてコマンドが実行された場合に発火するポイント。 これがないと、どれだけ実行しても空振りに終わります。

eの中身としては次のものが入っています。

  • trigger_id
  • response_url
  • user_name
  • team_domain
  • text
  • api_app_id
  • user_id
  • channel_name
  • token
  • channel_id
  • is_enterprise_install
  • team_id
  • command

今回使うものとしては、user_name, channel_name, token だけなので説明は割愛します。

あと、本来は分けて実装した方が良さそうな気もしましたが、認証周りもまとめてdoPost内に記載してます。

slack tokenが異なる場合はエラーを投げます。

一方でチャンネルが異なる場合は自分のみ閲覧可能な警告メッセージを返すようにしてます。

さてコード自体の解説はこんなもんにしてSlackへの投稿部分に関しての解説に移ろうかなと思います!

3.3. Slackへの投稿部分の補足

肝となっているのは実はここだけです。

return ContentService
    .createTextOutput(
      JSON.stringify({
        "response_type": "in_channel",
        "text": outputText
      })
    )
    .setMimeType(ContentService.MimeType.JSON);
  • ContentServiceおよびcreateTextOutput

GAS側で用意されているテキストコンテンツを返すserviceになります。

詳細は公式にて。

ちなみにSlack APIの制約で、レスポンスが3秒以内に返ってこない場合はエラーを返してしまうようです。

どうしても重くなりがちな処理をやらせたい場合は、タスクをキューに追加して定期的に監視・実行する方法もあるようですが、今回は本題からずれそうなので割愛します。

  • JSON.stringifyの中身

response_typeにはephemeralin_channelの2種類が存在します。

ephemeralを設定すると、自分のみ閲覧可能な形式で返ってきます。

警告などチャンネルに投稿するべきではないメッセージの表示をする際に設定してあげるといいのかなと。

個人的にはデバッグ時に設定することで、周りに「あいつ何かやってんな???」と悟られないのでオススメです(笑)

in_channelを設定すると、コマンドを実行したチャンネルに投稿する形になります。

  • <@${member.slackUserId}>

いつもの癖で@ユーザー名でメンションを付けようとすると、通常のテキストとして認識されてしまうため、Slack用の書き方を適用する必要性があります。

とは言っても@をつけてslackのuserIdを付けたものを<>で囲むだけです!

  • <!subteam^${TEAM_IDS[team]}>

グループメンションする際も同様にSlack用の書き方を適用させます。

こちらは@の代わりに!subteam^にしてやる必要性があるので要注意!

3.4. Webアプリとして公開

デプロイ > 新しいデプロイを選択。

アクセスできるユーザーを全員にしてデプロイを実行!

デプロイ実行!
デプロイ実行!

※全員にしないとslack側で所属アカウントか判定できないため弾かれます。

デプロイが完了したらウェブアプリのURLが発行されるので控えておきます。

デプロイ完了
デプロイ完了

4. スラッシュコマンドの適用

長らくお待たせしました。本題のスラッシュコマンド化です。

Features > Slash Commandsへ移動し、Create New Commandを選択。

URL直叩き用: https://api.slack.com/apps/ワークスペースID/slash-commands?

このような画面が表示されるので、必要な項目を入力します。

スラッシュコマンド実装
スラッシュコマンド実装

  • Command: お好きなスラッシュコマンド
  • Request URL: 先ほど控えておいたウェブアプリのURL
  • Short Description: 概要
  • Usage Hint: 引数とかを設定した場合の説明など

Saveを押下したらSlackのクライアント側へ移動してみましょう。

5. いざスラッシュコマンドを実行!

ここまできたらあとは実行するだけです。

今回は元のカスタムコマンドに沿う形で/nyuryoku_gijiroku(元々は入力チーム議事録担当と打つ必要性があった)を設定したので、/nまで打てば補完が効いて表示されます。

コマンドプレビュー
コマンドプレビュー

そのまま選択して実行すると、、、

実行結果
実行結果

こんな感じで実行されました!

これで毎朝「入力チーム議事録担当」とタイピングしなくて済む。。

6. まとめ

というわけで、無事にスラッシュコマンドを実装できました!

今回は抽選botというありきたりなものになりましたが、GASをうまいこと使ってやれば、カレンダーから会議室とメンバーの空き具合を取得してメールで返すなんてこともできそうなので、機会があったらそちらも実装・記事にしていこうかなと思います!

むしろそっちを早めに実装した方が1on1のために1週間分の会議室の空き状況を拾う手間が省けるの良いかも。。

ま、まあやること、やりたいこと、やれることが多いのは良いことなので!

最後までお付き合いいただきありがとうございました!

また、現在弊社ではエンジニアを募集しています!
この記事を読んで少しでも興味を持ってくださった方は、ぜひカジュアル面談でお話ししましょう!
iimon採用サイト / Wantedly / Green

参考文献