⚡️

Slack WorkflowをDeno Slack SDKで作ってみた

2024/12/11に公開1

本記事は株式会社ココナラ Advent Calendar 2024 11日目の記事です。

こんにちは!株式会社ココナラマーケットプレイス開発部でQAチームのチームマネージャをしているまるです。今回はSlackのワークフローをDeno Slack SDKを利用して開発し、開発フローの一部を自動化した話をします!

背景

まずは背景として自動化対象のフローから説明します。弊社では内部統制上、開発に対する承認をGithubのissueで管理し、Slackにて承認依頼を行っています。そして1つの開発に対して「開発着手」と「テスト着手」の2つの承認をそれぞれ得る必要があります。

またそれぞれの承認に対して承認部門が複数あり、この部門は開発のリスクレベルに応じて必要だったり不要だったりします。さらに開発部門の承認者は開発のリスクレベルに応じて変わります。まとめると以下のようになります😭

利用部門
(開発着手)
開発部門
(開発着手)
マネージャー
(テスト着手)
チームメンバー/QA
(テスト着手)
リスクA 利用者(PdMなど) VPoE、技術戦略室室長 開発担当役員 VPoE、技術戦略室室長、QAチームリーダー
リスクB 利用者(PdMなど) 部長、グループマネージャー 部長、グループマネージャー チームメンバー/QAメンバー
リスクC 利用者(PdMなど) チームマネージャー チームマネージャー -(不要)

実際の依頼風景はこのような感じです。
実際の承認風景
なかなかカオス

問題点

お察しの通り、上記フローにおいて運用上、以下のような問題がありました。

  1. リスクレベルに応じて承認先が変わるため、依頼者の認知負荷が高い。
  2. 承認者が多忙のため、依頼者による手動リマインドが発生している。

この問題の解決がフロー自動化の目的になります。

解決策の検討

既存フローから大きく変更してしまうとコストが増えてしまうので、以下は固定にすることにしました。

  1. 承認はGithubのissue
  2. 依頼方法はSlack

この前提のもと、課題に対する以下のアプローチを自動化したいです。

問題点 アプローチ
リスクレベルに応じて承認先が変わるため、依頼者の認知負荷が高い。 承認タイプ・リスクレベルに応じて入力項目を動的に変更。もしくは自動入力。
承認者が多忙のため、依頼者による手動リマインドが発生している。 依頼内容をデータストアに保存し、ステータスを管理。未承認の依頼に対して定期的にリマインド。

依頼方法はSlackなので、上記すべてをSlackで、欲を言えばノーコードでワークフローを構築できると理想的でした。しかし以下の問題でノーコードでの実現は断念しました😭

  1. ノーコードでは処理の分岐ができず、入力項目を動的に変更できない。
  2. データストアとしてリスト機能の利用を考えたが、カラムタイプの「メッセージ」がmessage_tsではなくリンクURLのため、リマインド時にやりとしているスレッドを特定できない。

上記技術的課題を解決するためにAWSのLambdaやDynamoDBなどを検討しているうちに、以下のドキュメントに辿り着きました!
https://api.slack.com/automation

Slack SDKSlack CLIを利用してワークフローベースのアプリを作成できます。この中にはInteractivityDatastoreのサポートがあります。
Datastoreについてはカラムの型にmessage_tsを指定でき、容易に問題を解決できることがわかりました。
https://api.slack.com/automation/types#datastores

承認時にSlack利用前提であればSlack内ですべて完結できたほうが管理しやすいと考え、今回はSlack SDKSlack CLIを利用してワークフローを開発することにしました。

Slack SDKには以下2種類あります。

SDK ホスト 言語
Deno Slack SDK Slack-host TypeScript
Bolt SDKs self-host Java, Python, JavaScript

今回は普段業務で利用している言語がTypeScriptだったのと、ホスト周りが億劫なのでDeno Slack SDKで開発することにしました。

ちなみに料金は、有料プランの契約をしていれば追加で請求されることはありません!
2024年9月25日からなのでナイスタイミングでした😎
https://slack.com/intl/ja-jp/help/articles/31660994380179-Slack-のワークフローにおける使用量ベースの請求に関する変更点

事前準備

方針が決まったので早速開発していきます。その前にSlackのDeveloper Programに登録しておきます。無料です。
https://api.slack.com/developer-program

これに登録しておくと、アプリ開発用のSlackワークスペースを利用することができます。このおかげで動作確認のため、アップロードしたアプリが予期せぬ悪さをしても、普段利用しているワークスペースに影響せずに開発できます。 とても開発しやすい!
sandbox
やりたい放題

インストール

ドキュメント通りにインストールします。
https://api.slack.com/automation/quickstart

1つハマったのは証明書周りです。私の場合はDenoをインストールするとエラーになってしまいました。結果的に環境変数DENO_CERTに証明書のパスを設定することでインストールできました。

開発

詳細はドキュメントに譲り、ここでは概要レベルを紹介します。
完成したワークフローのチャートはこちらです。サブグラフがワークフロー、ノードがステップを表現しています。

ワークフローからWebhookトリガーを利用して別のワークフローを呼び出すワークフローが爆誕しました(笑)
「ステップの中で承認すればいいじゃん」と思いますよね?そうしてしまうとそのステップが承認されるまで、後続のステップが実行されない挙動になります。これだと承認フローのリードタイムが長くなってしまいます。そのため承認は別ワークフローとして切り出しているという背景です。

実装

最初こそドキュメントを読みながら少しずつでしたが、慣れると簡単です。パッケージは以下のような構成になっています。

|--.devcontainer
|--.env
|--.env.deploy
|--.git
|--.gitignore
|--.slack
|--.vscode
|--LICENSE
|--README.md
|--assets
|--datastores       # ワークフローで利用するデータストアの定義ファイル
|--deno.jsonc
|--functions        # ワークフローで利用する各ステップ毎の定義ファイル。
|--import_map.json
|--manifest.ts
|--slack.json
|--triggers         # ワークフロー実行時のトリガーを定義ファイル。
|--types
|--workflows        # ワークフロー定義ファイル。functions配下のステップを直列に繋げる。

実装の順番は以下の通りにすると作りやすかったです。

順番 定義 やること ドキュメント
1 datastores モデル定義 https://api.slack.com/automation/datastores
2 functions ワークフローを細かいステップに分解して1つずつ実装。 https://api.slack.com/automation/functions
3 workflows 作成したステップを組み立てる。 https://api.slack.com/automation/workflows
4 triggers 4種類のトリガーのうちどれにするか決める。 https://api.slack.com/automation/triggers

方針検討時の課題に対するアプローチ

前述で今回Deno Slack SDKを利用することになった課題に対して、どうアプローチをしたか紹介します。

動的にモーダル上の入力項目を変える

リスクレベルに応じて承認先が異なるため、認知不可が高い問題への解決です。

handler.ts
export default SlackFunction(
    GetApprovementFunctionDefinition,
    async ({ inputs, client }) => {
        // リスクレベル入力モーダルを表示
        // ・・・
    },
).addViewSubmissionHandler([RISK_VIEW], ({ view }) => {
    const { modalView } = riskInputLogic(view);

    return {
        response_action: "update",
        view: modalView,
    };
}).addViewSubmissionHandler([CONFIRM_VIEW], ({ env, view }) => {
    // ・・・

まずリスクレベルを入力してもらいます。送信されたらaddViewSubmissionHandlerでリスクレベル送信eventをハンドルし、ハンドラーの中でriskInputLogicを呼び出します。この中でリスクレベルに応じてモーダルの入力項目を変えてます。

risk-input.ts
export default (
  view: View,
): { approveInfo: ApproveInfo; modalView: ModalView } => {
  // ・・・
  // リスクレベルに応じてモーダルの中身を出し分け
  if (approveInfo.development.riskLevel === "A") {
    return {
      approveInfo: approveInfo,
      modalView: riskAInputView(approveInfo),
    };
  }
  if (approveInfo.development.riskLevel === "B") {
    return {
      approveInfo: approveInfo,
      modalView: riskBInputView(approveInfo),
    };
  }
  return {
    approveInfo: approveInfo,
    modalView: riskCInputView(approveInfo),
  };
};

こうすることで依頼者はモーダルに表示された項目を入力するだけで、承認に必要十分な情報を入力できます。
実際のモーダルは以下のようになります。リスクAとリスクCで入力項目が異なるのがわかります。
リスクA
リスクA
リスクC
リスクC

データストアに承認情報を管理する

SlackがAPIを用意してくれているので簡単でした。まず承認依頼されたらデータストアに保存します。

put_dev_approve_function.ts
export default SlackFunction(
  PutDevApproveFunctionDefinition,
  async ({ inputs, client }) => {
    // ・・・
    const putResponse = await client.apps.datastore.put<
      typeof ApproveDevelopmentDatastore.definition
    >({
      datastore: ApproveDevelopmentDatastore.name,
      item: inputs.approveDevInfo,
    });
    // ・・・
  },
);

最後に承認者が承認ボタンを押下したらステータスを承認済みに変更します。

update_dev_approve_function.ts
    // ・・・
    const updateRes = await client.apps.datastore.update<
        typeof ApproveDevelopmentDatastore.definition
    >({
        datastore: ApproveDevelopmentDatastore.name,
        item: {
            id: inputs.approveDevInfo.id,
            develop_approve_status:
                inputs.approveDevInfo.developApproveStatus,
            modified_ts: Math.floor((new Date()).getTime() / 1000),
        },
    });
    // ・・・

リマインド時の取得も簡単です。getid指定時にしか使えないので、queryを利用して未承認を抽出しています。

get_unapproved_dev_function.ts
    const res = await client.apps.datastore.query({
      datastore: ApproveDevelopmentDatastore.name,
      expression:
        "(#using_approve_status <> :using_approve_status OR #develop_approve_status <> :develop_approve_status) AND #created_ts < :created_ts",
      expression_attributes: {
        "#using_approve_status": "using_approve_status",
        "#develop_approve_status": "develop_approve_status",
        "#created_ts": "created_ts",
      },
      expression_values: {
        ":using_approve_status": true,
        ":develop_approve_status": true,
        ":created_ts": getRecentlyTimestamp(), // 直近1時間以内の依頼はしつこいので除外
      },
    });

以上です!データストアもSlackアプリ内で簡潔&完結にできるのは助かります!

Deno Slack SDKを利用してみた感想

最後に実際に利用してみた感想を書いていきます。

テストが書きやすい!

理由は以下の通りです。

  1. Deno自体にテストフレームワークが用意されています。
  2. ロジックはfunctionsに切り出されている。
  3. functionsはinputとoutputを定義するため、自ずと振る舞い駆動のテストケースになる。
  4. SlackAPI用のテストダブルが装備されている。

今回の場合、リスクレベルに応じて適切に入力項目が表示されているかもテスト可能です。

confirm_test.ts
Deno.test("リスクレベルAかつ開発承認を選択時、開発部門が設定されていること", () => {
  approveInfo.development.risk_level = "A";
  approveInfo.test.risk_level = "A";
  approveInfo.development.id = "123e4567-e89b-12d3-a456-426614174000";
  approveInfo.development.develop_department = [];
  // 開発承認を選択しているときは設定済み
  approveInfo.development.ticket_link = "https://example.com";
  view.private_metadata = JSON.stringify(approveInfo);
  env.RISK_A_DEV_APPROVER = '["dev_user"]';
  view.state.values.using_department = {
    action: {
      selected_users: ["using_user"],
    },
  };

  const { approveInfo: result } = handler(env, view);

  assertEquals(result.development.develop_department, ["dev_user"]);
});

Deno.test("リスクレベルAかつ開発承認を未選択時、開発部門が未設定であること", () => {
  approveInfo.development.risk_level = "A";
  approveInfo.test.risk_level = "A";
  approveInfo.development.id = "";
  approveInfo.development.develop_department = [];
  view.private_metadata = JSON.stringify(approveInfo);
  view.state.values.ticket_link = {
    action: {
      value: "https://example.com",
    },
  };
  env.RISK_A_DEV_APPROVER = '["dev_user"]';

  const { approveInfo: result } = handler(env, view);

  assertEquals(result.development.develop_department, []);
});

Slackにメッセージ送信する関数もテストダブルを利用して簡単にテストできます。

send_request_function_test.ts
import * as mf from "https://deno.land/x/mock_fetch@0.3.0/mod.ts";
// ・・・

mf.install();

Deno.test("承認依頼のメッセージを送信できること", async () => {
  mf.mock("POST@/api/chat.postMessage", () => {
    return new Response(
      `{"ok": true, "message": {"ts": "1730000000.000000"}}`,
      {
        status: 200,
      },
    );
  });

  const { outputs } = await handler(createContext({ inputs }));

  assertEquals(outputs?.message_ts, "1730000000.000000");
});

動作確認が簡単!

$ slack run

これだけでローカルサーバーを起動して、作成中のSlackアプリをローカルアプリとしてデプロイできます!特にすごいのが、コードを修正して保存すると修正内容が即時反映されます!そのため爆速で動作確認ができます!

デプロイが簡単!

$ slack deploy

これだけで、指定したSlackワークスペースに作成したSlackアプリがデプロイされます。一度デプロイしたものを更新したい場合も、同じコマンドを実行することで最新化されます。

入力途中のモーダルから離脱したとき適切にワークフローを停止できない

現状以下の3択しかない😭

  1. 後続のステップすべてに分岐を入れて最後のステップまで実行させる。
  2. 異常終了として扱う(=SlackBotからメンションが来る)。
  3. ワークフローを実行中のままにする。

https://api.slack.com/automation/interactive-modals#stop-workflow

3に逃げたいけど気持ち悪い。大変ですが1で対応しました。ただ、改善はしてほしいです🙏

一度にdeployできるトリガーは1アプリ1つまで

今回みたいにトリガーが多い場合、残りのトリガーは1つずつ手動でdeployするしかない。本来の利用方法とズレてるからですかね🤔

slack trigger create --trigger-def "triggers/trigger_name.ts"

最後に

以上、Deno Slack SDKを利用したフローの自動化でした!本当は実際に使用後の感想とかも書きたかったのですが、アドベントカレンダー公開までに間に合いそうになく、諦めました😭
これからは実際のニーズに合わせて修正していければと考えてます!

明日は@ys_coconalaさんによる 「みんなー! 会計システムってどうしてるー!?」 です。

ココナラでは積極的にエンジニアを採用しています。

採用情報はこちら。
https://coconala.co.jp/recruit/engineer/

カジュアル面談希望の方はこちら。
https://open.talentio.com/r/1/c/coconala/pages/70417

Discussion

Kazuhiro SeraKazuhiro Sera

素晴らしいアプリですね!分岐のところは確かにもうちょっと改善されると開発しやすいですよね。。

ちなみに datastore で query を使われていますが、データ件数が増えてくると pagination を実装しないとデータが取れなくなってしまいます(これも落とし穴といえば落とし穴ですね・・)。なので、私が個人的に開発している O/R マッパーを使ってもらうか(デフォルトで自動的に pagination します)、似たようなことを自前でしてもらう必要があります: https://github.com/seratch/deno-slack-data-mapper

また、何かイケてるワークフローを作ったらぜひ記事にしてください! 👏