📑

【Slack】Event Subscriptionsを使って勤怠管理アプリを作ろう(GAS × Slack × スプシ)

2023/06/16に公開

はじめに

業務委託先で使用する業務委託用の勤怠管理アプリをGASとSlackとスプシで作りました。

筆者はGASを本格的に触ったことがなく、今回の実装を通して地雷を踏みまくったのでまた実装する時に同じミスをしないよう、実装フローに沿ってハマった箇所のメモを残そうと思います。

※業務委託先から一部コードの公開の許可をもらっています。

個人的にハマった箇所

今回紹介する「個人的にハマった箇所」を踏まえたコードは最後に載せているので、もし説明不要でしたら下部の「まとめ」までスキップしてください。

claspでGASの開発環境を構築する

GASにはGoogleが提供しているWEBエディターでプログラムを組むことができますがclaspを使うことでよりスムーズに開発を進めることができます。

https://github.com/google/clasp

筆者も最初はWEBエディターで作業をしていたのですが、コードが肥大化していく中で色々とつらみを感じたので途中でclaspを使った開発環境に乗り換えました。

今回の勤怠アプリの開発環境は次のテンプレートを使用させていただきました。
ありがとうございます。

https://zenn.dev/ryo_kawamata/articles/2c6cc4e7c27210

本業の話になってしまいますが、業務効率化で昔のメンバーが作ってくれたGASコードが「WEBエディターに直接書いてしまって権限的にもう閲覧することができない」なんてことがあったのでGitHubでソースコードを管理・共有できるのは良いかもしれません。

APIキーなどの秘匿情報を管理する

開発環境を構築しましたが次にAPIキーなどの秘匿情報をどのように管理すべきなのか調べました。

結論としてGASではスクリプトプロパティに秘匿情報を保存することができるようです。

勤怠アプリではソースコードをメンバーに共有する前提だったので慣れ親しみのある.envファイルでの秘匿情報管理を採用していたのですが、デプロイしてGASのWEBエディターに出力されるタイミングで秘匿情報が公開されてしまうのため、スクリプトプロパティでの秘匿情報管理に切り替えました。

「一部のスコープへのアクセス権限がありません」エラーを解決する

開発環境の構築が終わり、ある程度GASの素振りも終わったのでスプシ連携に移っていくのですが「一部のスコープへのアクセス権限がありません」と警告が出ました。

個人のスプシを使っていましたし、多少乱暴にしても問題ないだろうと思っても権限周りは怖いものです。

下記の記事で解決しました。

https://qiita.com/zk_phi/items/7220b67afa84c58cc160

「Outgoing Webhooks」ではなく「Event Subscriptions」を使おう

一旦スプシ連携が終わりましたが、どうやったら「Slack投稿して投稿内容をスプシに書き込み、書き込みが完了したらSlackにメッセージを返す双方向のやりとり」ができるのでしょうか?

いくつか「勤怠アプリ slack」で調べてみるとOutgoing WebhooksやらEvent Subscriptionsが必要になることがわかりました。

さらに深掘って調査を進めていくと、カスタムインテグレーション(Outgoing Webhooks)が非推奨になったのでSlack App(Event Subscriptions)を使わなくてはいけないようです。

Outgoing Webhooksでも動きはしますが、公式が非推奨としているので今回はEvent Subscriptionsを使った勤怠アプリを作っていきます。

GASでPOSTリクエストを受け取るためにdoPost関数を作る

Slack AppのダッシュボードでEvent Subscriptionsを有効にし、開発をしていきますが 「Your URL didn’t respond with the value of the challenge parameter.」 エラーが発生します。

下記のようにEvent SubscriptionsのPOST先URL検証のための実装を施しているのにエラーが発生しますし、同じ現象が起きているような記事も見当たりませんでした。

  const params = JSON.parse(e.postData.getDataAsString());

  if (params.type === "url_verification") {
    return ContentService.createTextOutput(params.challenge);
  }

色々調べてみた結果、この不具合はRequest Verificationに関連するものではなく「GASでPOSTリクエストを受け取れるようにするためdoPost関数で実装していなかった」ことが原因だとわかりました。

公式にもそのように記載されています。

https://developers.google.com/apps-script/guides/triggers?hl=ja#getting_started

すごく単純なミスですが、この関数名のルールを守らないと「ちゃんとSlackのEvent SubscriptionsのRequest Verificationのハンドリングをしているのにエラーが出る...」なんてことにハマってしまうので気をつけてください。

Slack APIを叩いてみる(ユーザー情報を取得する)

URL検証が通るようになりGASでSlackからのPOSTリクエストを受け取れるようになりました。

早速Slackが提供しているAPIを叩いてSlack投稿したユーザーの情報を取得し、ユーザー名を返す実装を通してEvent Subscriptionsが期待通り動いているか確認していきましょう。

使用するAPIは以下のusers.infoです。

https://api.slack.com/methods/users.info

カスタムインテグレーション(Outgoing Webhooks)の記事を見ているとSlack App(Event Subscriptions)の時とuidの取得方法が異なるので注意してください。

カスタムインテグレーション(Outgoing Webhooks)の場合はe.parameter.user_idでuidを取得できますがSlack App(Event Subscriptions)では次のようになります。

  const params = JSON.parse(e.postData.getDataAsString());
  
  const user = params.event.user; // uid

さらに、ユーザー情報を取得する全体のコードは次のようになります。

export const getUserName = (params: any) => {
  const jsonData = {
    token: process.env.BOT_USER_OAUTH_TOKEN,
    user: params.event.user,
  };

  const res = UrlFetchApp.fetch("https://slack.com/api/users.info", {
    method: "get",
    contentType: "application/x-www-form-urlencoded",
    payload: jsonData,
  });

  const userInfo = JSON.parse(res.getContentText());

  return userInfo.user.real_name;
};

あとは取得したユーザー名をIncoming Webhooksを使って(botが)Slackに返します。

Slackの返信用にIncoming Webhooksを使用していますが、こちらは以前に導入の記事を作っていますのでそちらを参考に実装してみてください。

https://zenn.dev/shuuuuuun/articles/36a980f97c4c34

botがbotの投稿に反応して無限ループする

Slackでユーザーが投稿した内容にbotが反応してリプライするところまで実装することができました。

しかし「ユーザー → Slack → bot → Slack」のように送信された場合、botの投稿時にもSlack AppのEventが発火され「bot → Slack → bot → Slack...」と無限ループしてしまいます。

そのため、botによる投稿は無視して、ユーザによる投稿にのみ処理する条件式を書く必要がありました。

  const params = JSON.parse(e.postData.getDataAsString());

  if ("subtype" in params.event) return;

上記の実装を加えることで無限ループを回避することができます。

Slackの3秒ルールで複数回レスポンスする

改めて無限ループしないSlackでのユーザー・bot間の双方向のやりとりが確認できたので、次に勤怠情報をスプシに書き込む実装を絡めて正常に動くか試していきます。

早速試してみると、botから2〜5回レスポンスが返されることがありました。なかなかスムーズにいかないものですね...

調査してみると、自分の環境で起きている現象と一致している記事を見つけました。

https://qiita.com/rh_taro/items/e5a76f24b744353d4bb9

上記の記事にも書かれているようにスプシに書き込む処理を加えたことで処理に時間がかかってしまいSlackの3秒ルールに引っかかった結果、リトライされているようです。

公式でもこの3秒ルールについて明記されていました。

https://api.slack.com/apis/connections/events-api#failure

いくつかの記事を参考に3秒ルールの対策を考えますが、レスポンスを3秒以内に収めるチューニングではなく下記のようにキャッシュすることでSlackの3秒ルールよる複数回レスポンスを避ける方法を採用しました。

スプシの処理が今後複雑化することが想定されるためキャッシュ対応の方がコスパが良いと考えたからです。

  const params = JSON.parse(e.postData.getDataAsString());

  const cache = CacheService.getScriptCache();
  if (cache.get(params.event.client_msg_id) == "done") return;
  cache.put(params.event.client_msg_id, "done", 600);

まとめ

ここまでくれば、Slackでユーザーとbotの双方向のやり取り(スプシ処理可能)ができるようになっているはずなので、あとは好みに合わせてスプシの読み取り・書き込み処理を書くのみになります。

「個人的にハマった箇所」を踏まえたコードは次になります。

const main = (e: any) => {
  const params = JSON.parse(e.postData.getDataAsString());

  // NOTE:SlackのEvent SubscriptionsのRequest Verification対策
  if (params.type === "url_verification") {
    return ContentService.createTextOutput(params.challenge);
  }

  // NOTE:botがbotの投稿に反応して無限ループするのを回避する
  if ("subtype" in params.event) return;

  // NOTE:Slackの3秒ルールで発生するリトライをキャッシュする
  const cache = CacheService.getScriptCache();
  if (cache.get(params.event.client_msg_id) == "done") return;
  cache.put(params.event.client_msg_id, "done", 600);

  // NOTE:以下からメインの処理
  const username = getUserName(params);
  const spreadsheet = getSpreadsheet(params, username);
  const sheet = getSheet(params, spreadsheet);
  updateSheet(params, sheet);

  return;
};

// NOTE:GASでSlackからのPOSTリクエストを受け取れるようにする
(global as any).doPost = main; 

最後に

GASのメイン開発(スプシ処理など)は思っていたより簡単だったのですが、その前の基盤まわりがネックだったなと思いました。

なので、ここまで説明してきた「個人的にハマった箇所」さえ抑えていただければ、GAS開発がよりスムーズに、楽しくなるのではないでしょうか?

勤怠アプリを作った後、ひとしきりGAS・Slack関連の記事を見ていくと、筆者が説明してきたハマりポイントは多くの方が記事として取り上げていたので、是非この記事で紹介している詳細のキャッチアップをお勧めします。

駆け足になりましたが、この記事がどなたかの参考になりましたら幸いです。最後までご覧いただきありがとうございました。

参考記事

Discussion