🐸

Slack次世代プラットフォームに入門してWFを複数作ったので詳しく紹介してみる。無料期間中(~2023年10月31日)に使い倒そう🚀

2023/09/08に公開

株式会社 IVRy (アイブリー) 社員番号 7番 エンジニアのボルドーです。

今回は Slack 次世代プラットフォーム (next generation Slack platform) のプロモーション期間終了が近いということで今さらながらしっかり使ってみようと思い調べたこと、サンプルのおすすめのキャッチアップ方法、実際に作成したワークフロー(WF)を書き溜めました
長いですがお付き合いください。

概要

Slack 次世代プラットフォームについて調べていると必ず行き着く下記記事に詳細にまとめられているのですが、私なりに理解したことをざっくりとまとめます。

  • Slack のワークスペース単位で Slack がアプリやデータのホスティングを行ってくれる環境を Slack CLI というコマンドラインツールを使って開発・公開できる
  • アプリはそれぞれ 1つ以上の ワークフロートリガーファンクション によって構成されている
    • 基本的な流れはワークフロービルダーを使った際の流れと同じで、再利用性が高い
    • 全体の処理をワークフローとして、複数の 組み込み/カスタム ファンクションを組み合わせて作成
    • そのワークフローをどのように起動させるかをトリガーとして独立して作成
      • ワークフローの入力条件だけ満たせば良いため、複数のトリガーによって起動させることが可能
      • トリガーにはタイプとして Event, Shortcut, Scheduled, Webhook の 4種類が用意されている
  • 組み合わせるファンクションによって スタンダードワークフローとプレミアムワークフローという区分がある
    • 有料プランの場合、スタンダードは無料、プレミアムは実行枠がプラン毎にあり、超過分は 1実行毎の従量課金
    • プロモーション期間として 2023年10月末までプレミアムワークフローの実行料金が無料

この度、自分で次世代プラットフォームを使ってアプリを作成してみて 読むだけでは全く理解できない ということがよくわかりました。実際私はわかった気になって意気揚々と社内で有志案件として募られていた Slack 便利ツールの開発を 9月から始めたのですが、動くものを作っていく中でハマりにハマって公式ドキュメントを読み込み、先人の知恵を借りて(ググって)ようやく最低限動くものができた、という感じでした[1]

ということで早速手を動かしていきましょう🚀[2]

Slack CLI のセットアップ

セットアップは以下のガイドの Step1 install と Step2 authorize を行ってください。
Step3 以降は本記事を読み進めていく中で取り扱うので Step2 までで問題ありません。

サンプルを見てみる

仕組みだけわかっても実際にどうしたら良いのかさっぱりなので、まずはサンプルを見てみましょう。
サンプルは以下にまとまっており、眺めていると理解が進みます。

サンプルを一括で作成する

とはいえ、Webページ上ではどこでどのように定義されているのか辿ることが難しいため、IDE で見たくなるかと思います。
これらのサンプルは slack create コマンドでテンプレートとして指定して作成することができるので、作成して IDE で見ることをおすすめします。

slack create -h
slack create -h
Create a Slack project on your local machine with an optional template

USAGE
  $ slack create [name] [flags]

FLAGS
  -b, --branch string     name of git branch to checkout
  -h, --help              help for create
  -t, --template string   template URL for your app

GLOBAL FLAGS
  -a, --app string           use a specific app ID or environment
      --config-dir string    use a custom path for system config directory
  -e, --experiment strings   use the experiment(s) in the command
  -f, --force                ignore warnings and continue executing command
      --no-color             remove styles and formatting from outputs
  -s, --skip-update          skip checking for latest version of CLI
      --token string         set the access token associated with a team
  -v, --verbose              print debug logging and additional info
  -w, --workspace string     select workspace or organization by domain name or team ID

EXAMPLE
  # Create a new project from a template
  $ slack create my-project

  # Start a new project from a specific template
  $ slack create my-project -t slack-samples/deno-hello-world

インタラクティブに view more で 1つずつ選択していくのは大変なので、こんな感じでテンプレートのリストを取得して一括作成すると便利です。
また、複数のサンプルを行き来する際に名前を覚えるのは大変なので index を prefix にして覚えておくと尚良しでした。

gh, jq が入っていなくて入れるのが面倒な方向け

2023年9月1日時点の実行結果

cat samples.txt
deno-reverse-string
deno-starter-template
deno-github-functions
deno-request-time-off
deno-blank-template
deno-hello-world
deno-welcome-bot
deno-incident-management
deno-daily-channel-topic
deno-give-kudos
deno-announcement-bot
deno-virtual-running-buddies
deno-message-translator
deno-code-snippets
deno-timesheet-approval
deno-triage-rotation
deno-simple-survey
deno-issue-submission
deno-function-template
# slack-samples organization の中から next-gen タグが付いている repo を探して name の一覧を取得
gh api /orgs/slack-samples/repos | \
  jq -r '.[] | select(.topics[] | contains("next-gen")) | .name' > samples.txt

# name の一覧から一括で slack create を実行
i=1
while read sample
do
  slack create `printf "%03d" ${i}`_$sample -t slack-samples/$sample
  # もしくは slack samples -t slack-samples/$sample
  i=`expr $i + 1`
done < samples.txt
unset i

# VSCode で開く例
open -a "Visual Studio Code" 001_deno-reverse-string

結果

サンプルからキャッチアップ

19サンプルを 1つずつ詳細に見ていくのも、実現したいことに近いサンプルを探して重点的に見ていくのも見方は人それぞれだと思いますが、いずれにせよ最初に manifest.ts を見るのが良さそうです。
各サンプルの manifest.tsgrep してみると botScopetypes, datastores から目当てのものを探しやすそうでした。

動的に trigger を使ってそうなサンプル
grep -r trigger */manifest.ts
006_deno-hello-world/manifest.ts:    "A sample that demonstrates using a function, workflow and trigger to send a greeting",
007_deno-welcome-bot/manifest.ts:    "triggers:write",
007_deno-welcome-bot/manifest.ts:    "triggers:read",
008_deno-incident-management/manifest.ts:    "triggers:write",
009_deno-daily-channel-topic/manifest.ts:    "triggers:write",
009_deno-daily-channel-topic/manifest.ts:    "triggers:read",
012_deno-virtual-running-buddies/manifest.ts:    "triggers:write",
013_deno-message-translator/manifest.ts:    "triggers:read",
013_deno-message-translator/manifest.ts:    "triggers:write",
016_deno-triage-rotation/manifest.ts:    "triggers:write",
017_deno-simple-survey/manifest.ts:    "triggers:read",
017_deno-simple-survey/manifest.ts:    "triggers:write",
CustomType を使ってそうなサンプル
grep -r "types:" */manifest.ts
011_deno-announcement-bot/manifest.ts:  types: [AnnouncementCustomType],
012_deno-virtual-running-buddies/manifest.ts:  types: [RunnerStatsType],
016_deno-triage-rotation/manifest.ts:  types: [UserArray],

その後は処理の順番通り trigger -> workflow -> functions の順番で実現したいことに近い処理をしているサンプルを見ていくのが良さそうです。
私は他の人の記事や公式ドキュメントを見ていて trigger と workflow は比較的すんなり理解できたのですが、functions の理解がなかなか進みませんでした。具体的には、サンプルを見るまで Web API を使って複雑な処理をしていくイメージが湧きませんでした。
サンプルを見たことでそのもやもやがスッキリしました。
特に 013_deno-message-translator では SlackFunction の第二引数 functionHandler にて inputs, client, env が渡され、それを用いて conversations.history や conversations.replies など馴染みのある API を利用している ので大変参考になりました。

Web API の conversations を使ってそうなサンプル
grep -r " client\.conversations" **/*.ts
008_deno-incident-management/functions/create_incident/interactivity_handlers/actions/close_and_archive.ts:    client.conversations.setTopic({
008_deno-incident-management/functions/create_incident/interactivity_handlers/actions/close_and_archive.ts:    client.conversations.archive({
008_deno-incident-management/functions/create_incident/interactivity_handlers/actions/create_channel.ts:    const createChannelResp = await client.conversations.create({
008_deno-incident-management/functions/create_incident/interactivity_handlers/actions/create_channel.ts:    client.conversations.setTopic({
008_deno-incident-management/functions/create_incident/interactivity_handlers/actions/create_channel.ts:    client.conversations.invite({
008_deno-incident-management/functions/create_incident/interactivity_handlers/actions/re-open.ts:    client.conversations.setTopic({
008_deno-incident-management/functions/create_incident/interactivity_handlers/view_submissions/add_participants.ts:      client.conversations.invite({
008_deno-incident-management/functions/create_incident/interactivity_handlers/view_submissions/all_clear.ts:      client.conversations.setTopic({
008_deno-incident-management/functions/create_incident/interactivity_handlers/view_submissions/edit.ts:    client.conversations.rename({
008_deno-incident-management/functions/create_incident/interactivity_handlers/view_submissions/edit.ts:    client.conversations.setTopic({
009_deno-daily-channel-topic/functions/ensure_channel_joined.ts:    const joinResponse = await client.conversations.join({
013_deno-message-translator/functions/internals/trigger_operations.ts:  const response = await client.conversations.join({ channel: channelId });
013_deno-message-translator/functions/internals/trigger_operations.ts:      const convo = await client.conversations.info({ channel: channelId });
013_deno-message-translator/functions/translate.ts:  let translationTargetResponse = await client.conversations.history({
013_deno-message-translator/functions/translate.ts:    translationTargetResponse = await client.conversations.replies({
013_deno-message-translator/functions/translate.ts:  const replies = await client.conversations.replies({
017_deno-simple-survey/functions/configure_events.ts:    const joinResponse = await client.conversations.join({ channel });
017_deno-simple-survey/functions/maintain_membership.ts:      const response = await client.conversations.join({ channel });

https://github.com/slack-samples/deno-message-translator/blob/main/functions/translate.ts

その他、Slack は Google Spreadsheet や GAS (Google Apps Script) がセットで使われている印象なので連携周りはどうやるのか などざっと眺めました。

Google 関連を使ってそうなサンプル
grep -r "Google" **/*.ts
015_deno-timesheet-approval/external_auth/google_provider.ts:const GoogleProvider = DefineOAuth2Provider({
015_deno-timesheet-approval/external_auth/google_provider.ts:    "provider_name": "Google",
015_deno-timesheet-approval/external_auth/google_provider.ts:export default GoogleProvider;
015_deno-timesheet-approval/functions/save_hours.ts:  description: "Store input hours in a Google sheet",
015_deno-timesheet-approval/functions/save_hours.ts:    // Collect Google access token
015_deno-timesheet-approval/functions/save_hours.ts:      return { error: `Failed to collect Google auth token: ${auth.error}` };
015_deno-timesheet-approval/manifest.ts:import GoogleProvider from "./external_auth/google_provider.ts";
015_deno-timesheet-approval/manifest.ts:  externalAuthProviders: [GoogleProvider],
015_deno-timesheet-approval/workflows/collect_hours.ts:  description: "Gather and save timesheet info to a Google sheet",
017_deno-simple-survey/external_auth/google_provider.ts:const GoogleProvider = DefineOAuth2Provider({
017_deno-simple-survey/external_auth/google_provider.ts:    "provider_name": "Google",
017_deno-simple-survey/external_auth/google_provider.ts:export default GoogleProvider;
017_deno-simple-survey/functions/create_google_sheet.ts:export const CreateGoogleSheetFunctionDefinition = DefineFunction({
017_deno-simple-survey/functions/create_google_sheet.ts:  description: "Create a new Google Sheet",
017_deno-simple-survey/functions/create_google_sheet.ts:        description: "The Google access token ID of the reactor",
017_deno-simple-survey/functions/create_google_sheet.ts:  CreateGoogleSheetFunctionDefinition,
017_deno-simple-survey/functions/create_google_sheet.ts:    // Collect Google access token
017_deno-simple-survey/functions/create_google_sheet.ts:        error: `Failed to collect Google auth token: ${auth.error}`,
017_deno-simple-survey/functions/create_survey_trigger.ts:        description: "The Google access token ID of the reactor",
017_deno-simple-survey/functions/save_response.ts:  description: "Store shared feedback in a Google sheet",
017_deno-simple-survey/functions/save_response.ts:        description: "The Google access token ID of the reactor",
017_deno-simple-survey/functions/save_response.ts:    // Collect Google access token of the reactor
017_deno-simple-survey/functions/save_response.ts:      return { error: `Failed to collect Google auth token: ${auth.error}` };
017_deno-simple-survey/manifest.ts:import GoogleProvider from "./external_auth/google_provider.ts";
017_deno-simple-survey/manifest.ts:  externalAuthProviders: [GoogleProvider],
017_deno-simple-survey/workflows/create_survey.ts:import { CreateGoogleSheetFunctionDefinition } from "../functions/create_google_sheet.ts";
017_deno-simple-survey/workflows/create_survey.ts:// Step 1: Create a new Google spreadsheet
017_deno-simple-survey/workflows/create_survey.ts:  CreateGoogleSheetFunctionDefinition,

https://github.com/slack-samples/deno-simple-survey/blob/main/functions/create_google_sheet.ts

実践

それでは実際に以下のものを作っていきます。

  1. 初級 & 無料(スタンダードワークフローなので制限なし)
    1. GUI で「Webhook を使ってチャンネルをアーカイブして固定チャンネルに通知するAPI」を作成する
    2. Slack CLI でチャンネルアーカイブ、絵文字追加 等のイベントトリガーワークフローを作成する
  2. 中級 & 有料(プレミアムワークフローなので期間終了後は制限付き)
    1. フォームに入力した内容から複数の Web API を実行して複数チャンネルへ一括招待するワークフローを実装する

1. 初級 & 無料(スタンダードワークフローなので制限なし)

1-1. GUI で「Webhook を使ってチャンネルをアーカイブして固定チャンネルに通知するAPI」を作成する

まずは肩慣らしにワークフロービルダーを使ってトリガー、ワークフロー、ファンクションがどのような関係なのか改めて確認してみます[3]

従来の Slack BOT を開発経験のある方であれば切望していた機能として、チャンネルへの招待やアーカイブといった、BOT Tokensでは制限を受ける[4]機能が組み込みファンクションとしてスタンダードワークフロー(つまり無料)で使えます。

BOT でチャンネル招待やアーカイブができない場合の手法を紹介している記事

Slack が用意している組み込みファンクションの一覧は以下のページで確認できます。

アーカイブは archive_channel ファンクションを使います。

全体の流れとしては以下のようになります。

  1. ワークフロービルダーを使ってチャンネルをアーカイブして通知するワークフローを Webhook トリガーで作成する
  2. 従来の BOT からアーカイブしたいチャンネルの ID を Webhook のパラメーターに指定して Web API のように POST リクエストする
  3. ワークフロー発火 => チャンネルをアーカイブして固定チャンネルへ通知する
手順詳細を見る

1. ワークフロービルダーを起動

Slack App の左上にあるワークスペース設定の 「ツール」 > 「ワークフロービルダー」からワークフロービルダーを起動します。

2. ワークフローを作成する

右上の「ワークフローを作成する」 > 「ゼロから始める」 > 「Webhook から」を選択します。
これが Slack CLI のトリガーに相当します。

3. Webhook にデータ変数を設定する

「変数を設定する」からキーに channel_id, データタイプに Slack チャンネルID を設定します。
その他に変数として渡したい項目があれば合わせて設定します。アーカイブするチャンネルの作成日や最終投稿日、参加していたメンバー等何かしらの補足情報があると特色が出るかもしれません。
その後、「続行する」を押します。

4. ワークフローに名前をつける

「無題のワークフロー」を端的な名前に変更します。

5. ワークフローにステップを登録する

右側ステップの「チャンネル」 > 「チャンネルをアーカイブする」 を選択します。
その後モーダルで「チャンネルを選択する」に先ほどデータ変数に設定した channel_id を選択します。
この各ステップが Slack CLI のファンクションに相当します。

続いて、次のステップとして「メッセージ」 > 「チャンネルへメッセージを送信する」を選択し、表示されたモーダルに通知先チャンネルとメッセージを設定します。
メッセージにチャンネル名を表示したい場合は「変数を挿入する」から行うことができます。

6. ワークフローを公開する

「公開する」を押してコラボレーター等を設定した後公開します。

7. Webhook URL を取得する

最後にステップの上にある「Webhook を使って開始する」からモーダルを開いて下部にある「ウェブリクエストの URL」をコピーします。

8. 動作を確認する

これで準備が整ったので、実際に curl で確認してみます。

curl -d '{ "channel_id": "CXXXXXX" }' \
     -H 'Content-Type: application/json;charset=utf-8' \
     -X POST https://hooks.slack.com/triggers/team_id/...
     
# => {"ok":true}

リクエストが成功するとこのように指定したチャンネルがアーカイブされ、通知先チャンネルに通知が飛びました。


アーカイブされたチャンネル



通知チャンネル

まとめ

channel_id を受け取る Webhook をワークフローのトリガーとして登録したので
ワークフローには channel_id が渡され、各ステップ(ファンクション)から参照可能となった。
その channel_id を使って「アーカイブする」「固定チャンネルへ通知する」という順番でファンクションを登録してワークフローを公開、最後に動作を確認した。

いかがでしょうか。ワークフロービルダーを使ってみると一層理解が進みませんかね。
この一連の流れが Slack CLI では公開前に確認できるしテストも書けるという とても便利なツールだということがわかります。

おまけ(この Webhook を使って作ったもの紹介)

この Webhook を作ることになったきっかけは一定期間経過したチャンネルを自動でアーカイブできないかと管理者から相談を受けたことでした。
その際の実装は後日別記事で紹介できたらと思っているのですが少しだけ紹介させてください。

内容としては複数の Web API を組み合わせて以下の処理を行っています。

  1. conversations.list でワークスペース内の全てのパブリックチャンネルを取得
  2. conversations.history で各チャンネルの最新投稿を数件取得
  3. thread_ts がある場合は conversations.replies でスレッドの投稿を全件取得
  4. 全ての投稿からチャンネル参加、退出等の subtype を除外した上で最新の投稿を探す
  5. その投稿が引数で指定した日数よりも古い投稿だったら Webhook URL を使ってアーカイブする
    • この時フラグで実際にはアーカイブせずにアナウンスだけできるようにする

というアプリを作りました。


dry-run で予告している様子(以前のアナウンスから 3日経っていたので辻褄を合わせるために 103日としている)


実際に Webhook URL を使ってアーカイブしている様子

1-2. Slack CLI でチャンネルアーカイブ、絵文字追加 等のイベントトリガーワークフローを作成する

そのうち公式のワークフロービルダーで出てきそうですが、執筆時点でこれらのイベントトリガーは見当たらない[5]のでチャンネルのアーカイブ、アンアーカイブ、改名やワークスペースへの絵文字追加などのイベントトリガーワークフローを用意しておくとワークスペース内の動きにいち早く気付けて便利かもしれません[6]
私の勘違いでなければ全種類のトリガーと組み込みファンクションは無制限で無料のはずです。

まずはトリガーにイベントを使ってそうなサンプルを見る

サンプルでは message_posted, reaction_added, channel_created を利用しているようです。

grep -rE "(TriggerTypes\.Event|TriggerEventTypes\.)" **/*.ts
009_deno-daily-channel-topic/triggers/message_posted_event.ts:  type: TriggerTypes.Event,
009_deno-daily-channel-topic/triggers/message_posted_event.ts:    event_type: TriggerEventTypes.MessagePosted,
013_deno-message-translator/triggers/sample_reaction_added_trigger.ts:  type: TriggerTypes.Event,
013_deno-message-translator/triggers/sample_reaction_added_trigger.ts:    event_type: TriggerEventTypes.ReactionAdded,
014_deno-code-snippets/Event_Triggers/triggers/channel_created.ts:  type: TriggerTypes.Event,
014_deno-code-snippets/Event_Triggers/triggers/channel_created.ts:  event: { event_type: TriggerEventTypes.ChannelCreated },
014_deno-code-snippets/Event_Triggers/triggers/reaction_added.ts:  type: TriggerTypes.Event,
014_deno-code-snippets/Event_Triggers/triggers/reaction_added.ts:    event_type: TriggerEventTypes.ReactionAdded,

https://github.com/slack-samples/deno-daily-channel-topic/blob/main/triggers/message_posted_event.ts

https://github.com/slack-samples/deno-message-translator/blob/main/triggers/sample_reaction_added_trigger.ts

https://github.com/slack-samples/deno-code-snippets/blob/main/Event_Triggers/triggers/channel_created.ts

実装手順① プロジェクト作成

用意されているイベントの一覧とトリガーの作成方法は以下のページにて確認できます。

サンプルでは message_posted, reaction_added, channel_created を利用しているので、今回は channel_unarchived イベントを使ってアーカイブしたチャンネルが再度オープン(解凍?)された際に発火するトリガーを作成してみます。
channel_archived, emoji_changed 等も同様の手順で作成できます。

まずは slack create {sample-name} で Blank Template でプロジェクトを作成します。

slack create trigger-samples
? Select a template to build from: [Use arrows to move]

  Issue submission (default sample)
  Basic app that demonstrates an issue submission workflow

  Scaffolded template
  Solid foundation that includes a Slack datastore

❱ Blank template
  A, well.. blank project

  View more samples

  Guided tutorials can be found at api.slack.com/automation/samples
? Select a template to build from: Blank template
⚙️  Creating a new Slack app in ~/path/to/trigger-samples

📦 Installed project dependencies

✨ trigger-samples successfully created

🧭 Explore the documentation to learn more
   Read the README.md or peruse the docs over at api.slack.com/automation
   Find available commands and usage info with slack help

📋 Follow the steps below to begin development
   Change into your project directory with cd trigger-samples/
   Develop locally and see changes in real-time with slack run
   When you're ready to deploy for production use slack deploy

その後 cd trigger-samples しておきます。

slack trigger --help
List details of existing triggers

USAGE
  $ slack trigger [flags]

ALIASES
  trigger, triggers

SUBCOMMANDS
  access      Manage who can use your triggers
  create      Create a trigger for a workflow
  delete      Delete an existing trigger
  info        Get details for a specific trigger
  list        List details of existing triggers
  update      Updates an existing trigger

FLAGS
  -h, --help   help for trigger

GLOBAL FLAGS
  -a, --app string           use a specific app ID or environment
      --config-dir string    use a custom path for system config directory
  -e, --experiment strings   use the experiment(s) in the command
  -f, --force                ignore warnings and continue executing command
      --no-color             remove styles and formatting from outputs
  -s, --skip-update          skip checking for latest version of CLI
      --token string         set the access token associated with a team
  -v, --verbose              print debug logging and additional info
  -w, --workspace string     select workspace or organization by domain name or team ID

EXAMPLE
  # Select who can run a trigger
  $ slack trigger access

  # Create a new trigger
  $ slack trigger create

  # Delete an existing trigger
  $ slack trigger delete --trigger-id Ft01234ABCD

  # Get details for a trigger
  $ slack trigger info --trigger-id Ft01234ABCD

  # List details for all existing triggers
  $ slack trigger list

  # Update a trigger definition
  $ slack trigger update --trigger-id Ft01234ABCD

EXPERIMENTS
  None

ADDITIONAL HELP
  For more information about a specific command, run:
  $ slack trigger <subcommand> --help

  For guides and documentation, head over to https://api.slack.com/automation

徐ろに create してみましたが、先に triggers ディレクトリ内にファイルを作成する必要があるようです。

slack trigger create
⚡ Searching for trigger definition files under 'triggers/*'...
   No trigger definition files found
   Learn more about triggers: https://api.slack.com/automation/triggers/link

Check /Users/user/.slack/logs/slack-debug-20230902.log for full error logs

🚫 --workflow or --trigger-def is required (mismatched_flags)
実装手順② trigger と workflow ファイルを作成する

まずはワークフローを作成します。
起動すると受け取ったパラメーターから channel_id を取り出して通知用のチャンネルへ投稿するだけのワークフローです。
通知先チャンネルは環境変数にしておくことも可能なようです。

workflows/channel_unarchived.ts
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";

// Workflow definition
const ChannelUnarchivedWorkflow = DefineWorkflow({
  callback_id: "channel_unarchived_workflow",
  title: "Channel Unarchived Workflow",
  input_parameters: {
    required: [
      "channel_id",
      "channel_type",
      "event_type",
    ],
    properties: {
      channel_id: {
        type: Schema.slack.types.channel_id,
        description: "channel ID.",
      },
      channel_name: {
        type: Schema.types.string,
        description: "channel name.",
      },
      channel_type: {
        type: Schema.types.string,
        description: "channel type.",
      },
      event_type: {
        type: Schema.types.string,
        description: "event type.",
      },
      user_id: {
        type: Schema.slack.types.user_id,
        description: "user ID.",
      },
      created: {
        type: Schema.types.string,
        description: "created.",
      },
    },
  },
});

ChannelUnarchivedWorkflow.addStep(Schema.slack.functions.SendMessage, {
  channel_id: "CXXXXXXX", // 通知先チャンネル
  message:
    `:open_hands: <#${ChannelUnarchivedWorkflow.inputs.channel_id}> が再度オープンされました!`,
});

export { ChannelUnarchivedWorkflow };
triggers/channel_unarchived.ts
import { Trigger } from "deno-slack-api/types.ts";
import {
  TriggerContextData,
  TriggerEventTypes,
  TriggerTypes,
} from "deno-slack-api/mod.ts";
import { ChannelUnarchivedWorkflow } from "../workflows/channel_unarchived.ts";

const trigger: Trigger<typeof ChannelUnarchivedWorkflow.definition> = {
  type: TriggerTypes.Event,
  name: "channel unarchive trigger",
  description: "アーカイブ済チャンネルが再オープンした際に発火するトリガー",
  workflow: `#/workflows/${ChannelUnarchivedWorkflow.definition.callback_id}`,
  inputs: {
    channel_id: {
      value: TriggerContextData.Event.ChannelUnarchived.channel_id,
    },
    channel_name: {
      value: TriggerContextData.Event.ChannelUnarchived.channel_name,
    },
    channel_type: {
      value: TriggerContextData.Event.ChannelUnarchived.channel_type,
    },
    event_type: {
      value: TriggerContextData.Event.ChannelUnarchived.event_type,
    },
    user_id: {
      value: TriggerContextData.Event.ChannelUnarchived.user_id,
    },
  },
  event: {
    event_type: TriggerEventTypes.ChannelUnarchived,
    team_ids: ["TEAM_ID"],
  },
};

export default trigger;

そして manifest.tsworkflowsChannelUnarchivedWorkflow を追加、 botScopechannels:read, chat:write, chat:write.public を 追加します。
参考: https://api.slack.com/automation/triggers/event

ここまでで channel_unarchived イベントで発火する ChannelUnarchivedWorkflow が作成され、組み込みファンクションの SendMessage を使って通知先である固定チャンネルへ投稿して終了する一連の流れが実装完了です。

では slack run でデバッグしてみます。

slack trigger create \
  --trigger-def triggers/channel_unarchived.ts \
  --workspace XXXXXX
# => 選択肢は Local を選択します

slack run
# => 実際にチャンネルをアーカイブしてみると指定した通知チャンネルに通知が来ることが確認できます

私がデバッグ中に遭遇したエラーは下記の通りです。
大体 https://api.slack.com/automation/cli/errors に記載があるのですが、コンソールエラーと同じ内容なので参考にはなりませんでした。

slack run
...
🚫 Workflow not found (workflow_not_found)
# => manifest.ts に workflow を登録していなかった

🚫 Channel ID specified doesn't exist or you do not have permissions to access it (invalid_channel_id)
# => 実在しないチャンネルID を指定していた

🚫 Slack API request parameters are invalid (invalid_arguments)
# => event.channel_ids を空にしていた(そもそも不要だった)

🚫 The provided event type is not allowed (invalid_trigger_event_type)
# => team_id を設定していなかった

特にハマったのが invalid_trigger_event_type でした。結論としては team_id の設定漏れだったのですが、19個もあるサンプルの中で team_id を設定しているものがなく コピペ元が channel_type のイベントだっため team_id の設定が必要だと気付きませんでした。

こちらの記事のおかげで気づくことができたので大変感謝しています。 future から automation にパスが変わったタイミングで channel_typeworkspace_type の記載が失われたのかもしれません。

https://api.slack.com/future/triggers/event#supported-events

ポイントとして、イベントトリガーはChannel typeとWorkspace typeに二分されており、前者の場合はtrigger定義のなかでチャンネルIDのコーディングが必要です。つまりデプロイ段階でそのアプリが稼働できるチャンネルが決定している必要があります。

workspace_type のイベントはここで確認できます。

https://github.com/slackapi/deno-slack-api/blob/2.1.1/src/typed-method-types/workflows/triggers/event.ts#L40-L56

実装手順③ ワークスペースへデプロイする

slack run で動作が確認できているのであとはもう公開するだけです。
留意点としては slack runslack deploy では環境が違うため、 slack run で確認するために作成したトリガーとは別の trigger-id だという点を理解しておく必要があります。
トリガーが 1つの場合は slack deploy だけで勝手に作ってくれるのですが、2つ目を作る際には slack trigger create を行う必要があります。

slack deploy 
# => 動作を確認

# インストールされている local が不要な場合はアンインストールします
slack delete --app local

実行例


チャンネルの作成とアーカイブ・アンアーカイブと絵文字追加を通知した実行例

2. 中級 & 有料(プレミアムワークフローなので期間終了後は制限付き)

もう少し実践的なことをしたい場合は有料となりますが、カスタムファンクション(ステップ)を組み合わせることで大体のことは実現できると思います。

2-1. フォームに入力した内容から複数の Web API を実行して複数チャンネルへ一括招待するワークフローを実装する

開発経緯

弊社では Slack ワークスペースへ参加すると必ず 自分専用のタイムズチャンネル time-〇〇 が作成されます。最近はメンバー数も増えてきてチャンネル毎のメンバー数のばらつきを気にする声が一部ですが上がっていました。
そこで話されていた内容にはユーザーグループのデフォルトチャンネルに指定してはどうかという意見もあり、それは絶対にやめたい[7]と思った私は任意で一括参加できて、自由に抜けられるようにしようと急遽このワークフローを作ることにしました[8]

処理の流れ

任意の実行者A がショートカットもしくはリンクからワークフローを起動した場合、処理の流れとしては以下のことをしています。なお、フォームには submitclose というボタンしか使えない(ラベル名は変更可能)のでどちらかわかるようにそのように記載しています。

  1. フォーム1 を表示してチャンネル名の絞り込み条件を入力して submit を押してもらう
  2. 入力条件に合致するパブリックチャンネルの一覧を取得
  3. 実行者A の参加しているパブリックチャンネルの一覧を取得
  4. それぞれの結果から 実行者A が未参加のチャンネル一覧と参加済みの一覧を抽出
  5. ワークフローボットの参加しているパブリックチャンネル一覧を取得
  6. 4 と 5 の結果から「今回招待対象だけどワークフローボットが未参加のチャンネル」へ参加させる[9]
  7. フォーム2 に 4の結果を表示して参加可能なチャンネルが 1件以上あれば submit を表示、0件なら close のみ表示
  8. submit だった場合 実行ユーザーをそのチャンネルへ一括で招待する
  9. フォーム3 に完了したことを表示して終了

実装

function

このカスタムファンクションだけで検索から招待まで一括で処理してしまっているので次世代プラットフォームの思想に反していますがご了承ください。
上述の通り、当初このファンクションはチャンネル一覧を検索してチャンネルIDのリストを返すカスタムファンクションのつもりで実装していました。その名残が命名に残ってしまっています。

import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";

export const SearchChannelsFunctionDefinition = DefineFunction({
  callback_id: "search_channels",
  title: "Search Channels",
  description: "Search for channels by name",
  source_file: "functions/searchChannels.ts",
  input_parameters: {
    required: ["interactivity"],
    properties: {
      interactivity: {
        type: Schema.slack.types.interactivity,
        description: "ワークフローを起動した際の interactivity",
      },
    },
  },
});

export default SlackFunction(
  SearchChannelsFunctionDefinition,
  async ({ inputs, client }) => {
    // step 1: フォームを表示して、検索条件を入力してもらう
    const response = await client.views.open({
      interactivity_pointer: inputs.interactivity.interactivity_pointer,
      view: {
        "type": "modal",
        "callback_id": "search_channels",
        "title": { "type": "plain_text", "text": "一括チャンネル招待" },
        "submit": { "type": "plain_text", "text": "検索する" },
        "close": { "type": "plain_text", "text": "Close" },
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text":
                "参加したいチャンネル名に共通する接頭辞、接尾辞または一部に含まれる文字列を入力してください。いずれか 1つ以上の入力が必須です。",
            },
          },
          {
            "type": "input",
            "block_id": "channel_prefix",
            "optional": true,
            "element": {
              "type": "plain_text_input",
              "action_id": "channel_prefix",
              "min_length": 2,
              "placeholder": {
                "type": "plain_text",
                "text": "time-",
              },
            },
            "label": {
              "type": "plain_text",
              "text": "チャンネル名の接頭辞(prefix)",
            },
          },
          {
            "type": "input",
            "block_id": "channel_suffix",
            "optional": true,
            "element": {
              "type": "plain_text_input",
              "action_id": "channel_suffix",
              "min_length": 2,
              "placeholder": {
                "type": "plain_text",
                "text": "-stg",
              },
            },
            "label": {
              "type": "plain_text",
              "text": "チャンネル名の接尾辞(suffix)",
            },
          },
          {
            "type": "input",
            "block_id": "includes",
            "optional": true,
            "element": {
              "type": "plain_text_input",
              "action_id": "includes",
              "min_length": 2,
              "placeholder": {
                "type": "plain_text",
                "text": "-sales-",
              },
            },
            "label": {
              "type": "plain_text",
              "text": "チャンネル名に含まれる文字列",
            },
          },
        ],
      },
    });

    if (!response.ok) {
      const error =
        `Failed to open a modal in the demo workflow. Contact the app maintainers with the following information - (error: ${response.error})`;
      return { error };
    }
    return {
      // このファンクションを終了させないために false を返す
      completed: false,
    };
  },
).addViewSubmissionHandler(
  "search_channels",
  async ({ inputs, view, client }) => {
    // step 2: 対象チャンネル一覧を取得する
    //         今回は追加取得はしないので、limit は 1000 で固定
    const prefix = view.state.values.channel_prefix.channel_prefix.value;
    const suffix = view.state.values.channel_suffix.channel_suffix.value;
    const includes = view.state.values.includes.includes.value;

    const searchedChannels = (await client.conversations.list({
      types: "public_channel",
      exclude_archived: true,
      limit: 1000,
    }).then((response) => {
      return response.channels;
    }).catch((error) => {
      console.error(error);
      return [];
    }))
      .filter((channel: { name: string; id: string }) => {
        if (prefix && suffix && includes) {
          return channel.name.startsWith(prefix) &&
            channel.name.endsWith(suffix) && channel.name.includes(includes);
        } else if (prefix && suffix) {
          return channel.name.startsWith(prefix) &&
            channel.name.endsWith(suffix);
        } else if (prefix && includes) {
          return channel.name.startsWith(prefix) &&
            channel.name.includes(includes);
        } else if (suffix && includes) {
          return channel.name.endsWith(suffix) &&
            channel.name.includes(includes);
        } else if (prefix) {
          return channel.name.startsWith(prefix);
        } else if (suffix) {
          return channel.name.endsWith(suffix);
        } else if (includes) {
          return channel.name.includes(includes);
        }
      });

    console.log("検索結果チャンネル数: ", searchedChannels.length);

    // step 3: user_id からチャンネル参加済み一覧を取得する
    console.log("user_id: ", inputs.interactivity.interactor.id);
    const excludeChannels = await client.users.conversations({
      user: inputs.interactivity.interactor.id,
      types: "public_channel",
      exclude_archived: true,
      limit: 1000,
    }).then((response) => {
      return response.channels;
    }).catch((error) => {
      console.error(error);
      return [];
    });
    console.log("参加済みチャンネル数: ", excludeChannels.length);

    // step 4: 対象チャンネル一覧から user_id が参加していないチャンネルを抽出する
    const filteredChannels = searchedChannels.filter(
      (channel: { id: string }) => {
        return !excludeChannels.some((excludeChannel: { id: string }) =>
          excludeChannel.id === channel.id
        );
      },
    );
    console.log("招待対象チャンネル数: ", filteredChannels.length);

    // step 5: BOT が参加していないチャンネルに BOT を参加させる
    const botChannels = await client.users.conversations({
      types: "public_channel",
      exclude_archived: true,
      limit: 1000,
    }).then((response) => {
      return response.channels;
    }).catch((error) => {
      console.error(error);
      return [];
    });
    for (const channel of filteredChannels) {
      if (botChannels.some((c: { id: string }) => c.id === channel.id)) {
        console.log("BOT が参加済みのチャンネル: ", channel.id, channel.name);
        continue;
      }
      await client.conversations.join({
        channel: channel.id,
      }).then((response) => {
        console.log("join response: ", response);
      }).catch((error) => {
        console.error(error);
      });
    }

    const query = [
      prefix ? `接頭辞: \`${prefix}\`` : "",
      suffix ? `接尾辞: \`${suffix}\`` : "",
      includes ? `部分一致: \`${includes}\`` : "",
    ].filter((q) => q).join(", ");

    const canInvite = filteredChannels.length > 0;

    // step 6: views を更新して結果を表示する
    return {
      response_action: "update",
      view: {
        "type": "modal",
        "callback_id": "result_channels",
        "title": { "type": "plain_text", "text": "検索結果" },
        "submit": canInvite
          ? {
            "type": "plain_text",
            "text": `${canInvite ? "Join" : "-"}`,
          }
          : undefined,
        "close": { "type": "plain_text", "text": "Close" },
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text":
                `*招待対象: ${filteredChannels.length}件* / 検索結果: ${searchedChannels.length}件(${query}`,
            },
          },
          {
            "type": "context",
            "elements": [
              {
                "type": "mrkdwn",
                "text": `${
                  !canInvite
                    ? "招待可能なチャンネルはありません。\n"
                    : filteredChannels.map((
                      channel: { name: string; id: string },
                    ) => "#" + channel.name).join("\n").slice(0, 2000)
                }`,
              },
            ],
          },
          {
            "type": "context",
            "elements": [
              {
                "type": "mrkdwn",
                "text": `${
                  searchedChannels.length === filteredChannels.length
                    ? "\n参加済みのチャンネルはありません。"
                    : "\n" +
                      searchedChannels.filter((channel: { id: string }) =>
                        !filteredChannels.some((
                          filteredChannel: { id: string },
                        ) => filteredChannel.id === channel.id)
                      ).map((
                        channel: { name: string; id: string },
                      ) => "\`#" + channel.name + "\`").join(", ").slice(
                        0,
                        2000,
                      )
                }`,
              },
            ],
          },
        ],
        // チャンネルIDの一覧を保存しておく
        "private_metadata": `${
          filteredChannels.map((channel: { id: string }) => channel.id).join(
            ",",
          )
        }`,
      },
    };
  },
).addViewSubmissionHandler(
  "result_channels",
  async ({ view, inputs, client }) => {
    // view に表示していたチャンネル一覧を取得する
    const channelIds = view.private_metadata?.split(",") || [];

    for (const channel of channelIds) {
      await client.conversations.invite({
        channel,
        users: inputs.interactivity.interactor.id,
      }).then((response) => {
        console.log("invite response: ", response);
      }).catch((error) => {
        console.error(error);
      });
    }

    return {
      response_action: "update",
      view: {
        "type": "modal",
        "callback_id": "completed",
        "notify_on_close": true,
        "title": { "type": "plain_text", "text": "完了しました" },
        "close": { "type": "plain_text", "text": "Close" },
        "blocks": [{
          "type": "section",
          "text": {
            "type": "mrkdwn",
            "text": `あなたを ${channelIds.length}件のチャンネルに招待しました`,
          },
        }],
      },
    };
  },
).addViewClosedHandler(
  ["completed"],
  () => {
    console.log("completed");
    return {
      completed: true,
    };
  },
);
workflow & trigger

結果としてワークフローはシンプルに addStep を一度しているのみとなっています。

import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
import { SearchChannelsFunctionDefinition } from "../functions/searchChannels.ts";

const BulkInviteWorkflow = DefineWorkflow({
  callback_id: "bulk_invite",
  title: "一括チャンネル招待",
  description: "特定のチャンネル群に一括で招待する",
  input_parameters: {
    properties: {
      interactivity: {
        type: Schema.slack.types.interactivity,
      },
    },
    required: ["interactivity"],
  },
});

BulkInviteWorkflow.addStep(
  SearchChannelsFunctionDefinition,
  {
    interactivity: BulkInviteWorkflow.inputs.interactivity,
  },
);

export default BulkInviteWorkflow;

トリガーはショートカットとしてワークフローを呼び出します。

import { Trigger } from "deno-slack-sdk/types.ts";
import { TriggerContextData, TriggerTypes } from "deno-slack-api/mod.ts";
import BulkInviteWorkflow from "../workflows/invite_to_channels.ts";

const trigger: Trigger<typeof BulkInviteWorkflow.definition> = {
  type: TriggerTypes.Shortcut,
  name: "Invite to channel",
  description: "実行者を一括で特定のチャンネル群に招待する",
  workflow: `#/workflows/${BulkInviteWorkflow.definition.callback_id}`,
  inputs: {
    interactivity: {
      value: TriggerContextData.Shortcut.interactivity,
    },
  },
};

export default trigger;
manifest
import { Manifest } from "deno-slack-sdk/mod.ts";
import BulkInviteWorkflow from "./workflows/invite_to_channels.ts";

export default Manifest({
  name: "BulkInviter",
  description: "特定のチャンネル群に一括で招待する",
  icon: "assets/default_new_app_icon.png",
  workflows: [BulkInviteWorkflow],
  outgoingDomains: [],
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
    "channels:read",
    "channels:history",
    "channels:write.invites",
    "groups:write.invites",
    "channels:join",
  ],
});

実行結果

1. 起動

2. フォームが開く

3. 検索結果

4. 完了


以上となります。
私は Deno の経験がなくてまだまだわからないことがたくさんありますが、GitHub Copilot のおかげで幾分か楽にやりたいことが実現できました。
皆さんもワークフローを量産して良い Slack ライフをお送りください🥂

記事内に記載できなかった参考記事

We are hiring!!

最後に、IVRy では一緒に働く仲間を絶賛募集中です。
今の所 順調に成長してきていますが、今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!

カジュアルに話を聞きたいという方は私の Meety から面談を申し込んでいただければ色々お話します。

脚注
  1. ということで執筆時点での私の次世代プラットフォーム歴は 1週間なので間違い・勘違いもそれなりにあるかもしれませんがご容赦くださいmm ↩︎

  2. とはいえ読み進めるだけでもある程度理解できるように私の理解の範囲内でコマンド結果も含めて詳しく書いたつもりです。 ↩︎

  3. 現状、ワークフロービルダーではできないことも Slack CLI ではできます。今回は GUI で完結できるものだったので対応関係を見るのに良さそうと思いワークフロービルダーを使いました。将来的には Slack CLI で作成したカスタムファンクション等もワークフロービルダーで使えるようにする方針らしいです。 ↩︎

  4. Slack の権限が複雑で私が理解できていないだけかもしれません。 ↩︎

  5. 「絵文字リアクションが使用された時」「誰かがチャンネルに参加した時」しか見当たりませんでした。 ↩︎

  6. 既にサードパーティ製のアプリで実現している方も多いかと思いますが... ↩︎

  7. 試したことはないですが、デフォルトチャンネルからは抜けられないらしく退出した瞬間に強制召喚されるらしいです。私は IVRy の基本パブリックチャンネルでやりとりし、個人チャンネルや趣味のチャンネルへの入退室自由な雰囲気がとても好きなのでそれだけはやめて欲しいという思いがありました。 ↩︎

  8. そういった意図から表示するフォームには招待されるユーザー指定項目は設けず、実行者に対する処理に限定しました。似た名前の人を間違えて指定してしまった場合など 100以上のチャンネルから 1つずつ選んで抜けるのは悲劇ですからね... ↩︎

  9. この処理に関しては 8 の実行前であればいつでも構いません。事前に全チャンネルに入れておけばいらぬ心配だと思います。 ↩︎

IVRyテックブログ

Discussion