Slack次世代プラットフォームに入門してWFを複数作ったので詳しく紹介してみる。無料期間中(~2023年10月31日)に使い倒そう🚀
株式会社 IVRy (アイブリー) 社員番号 7番 エンジニアのボルドーです。
今回は Slack 次世代プラットフォーム (next generation Slack platform) のプロモーション期間終了が近いということで今さらながらしっかり使ってみようと思い調べたこと、サンプルのおすすめのキャッチアップ方法、実際に作成したワークフロー(WF)を書き溜めました。
長いですがお付き合いください。
概要
Slack 次世代プラットフォームについて調べていると必ず行き着く下記記事に詳細にまとめられているのですが、私なりに理解したことをざっくりとまとめます。
- Slack のワークスペース単位で Slack がアプリやデータのホスティングを行ってくれる環境を Slack CLI というコマンドラインツールを使って開発・公開できる
- Slack がホスティングする Datastores に安全にデータを保管できる
- Slack CLI によって管理画面との行き来がなくなりコマンドだけでアプリ公開まで完結する
- 逆に管理画面からは "Apps created using the CLI can only be managed through the 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.ts
を grep
してみると botScope
や types
, 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 });
その他、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,
実践
それでは実際に以下のものを作っていきます。
1. 初級 & 無料(スタンダードワークフローなので制限なし)
1-1. GUI で「Webhook を使ってチャンネルをアーカイブして固定チャンネルに通知するAPI」を作成する
まずは肩慣らしにワークフロービルダーを使ってトリガー、ワークフロー、ファンクションがどのような関係なのか改めて確認してみます[3]。
従来の Slack BOT を開発経験のある方であれば切望していた機能として、チャンネルへの招待やアーカイブといった、BOT Tokensでは制限を受ける[4]機能が組み込みファンクションとしてスタンダードワークフロー(つまり無料)で使えます。
BOT でチャンネル招待やアーカイブができない場合の手法を紹介している記事
Slack が用意している組み込みファンクションの一覧は以下のページで確認できます。
アーカイブは archive_channel ファンクションを使います。
全体の流れとしては以下のようになります。
- ワークフロービルダーを使ってチャンネルをアーカイブして通知するワークフローを Webhook トリガーで作成する
- 従来の BOT からアーカイブしたいチャンネルの ID を Webhook のパラメーターに指定して Web API のように POST リクエストする
- ワークフロー発火 => チャンネルをアーカイブして固定チャンネルへ通知する
手順詳細を見る
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 を組み合わせて以下の処理を行っています。
- conversations.list でワークスペース内の全てのパブリックチャンネルを取得
- conversations.history で各チャンネルの最新投稿を数件取得
- thread_ts がある場合は conversations.replies でスレッドの投稿を全件取得
- 全ての投稿からチャンネル参加、退出等の subtype を除外した上で最新の投稿を探す
- その投稿が引数で指定した日数よりも古い投稿だったら 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,
実装手順① プロジェクト作成
用意されているイベントの一覧とトリガーの作成方法は以下のページにて確認できます。
サンプルでは 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 を取り出して通知用のチャンネルへ投稿するだけのワークフローです。
通知先チャンネルは環境変数にしておくことも可能なようです。
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 };
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.ts
の workflows
に ChannelUnarchivedWorkflow
を追加、 botScope
に channels: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_type
と workspace_type
の記載が失われたのかもしれません。
https://api.slack.com/future/triggers/event#supported-events
ポイントとして、イベントトリガーはChannel typeとWorkspace typeに二分されており、前者の場合はtrigger定義のなかでチャンネルIDのコーディングが必要です。つまりデプロイ段階でそのアプリが稼働できるチャンネルが決定している必要があります。
workspace_type のイベントはここで確認できます。
実装手順③ ワークスペースへデプロイする
slack run
で動作が確認できているのであとはもう公開するだけです。
留意点としては slack run
と slack 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 がショートカットもしくはリンクからワークフローを起動した場合、処理の流れとしては以下のことをしています。なお、フォームには submit
と close
というボタンしか使えない(ラベル名は変更可能)のでどちらかわかるようにそのように記載しています。
- フォーム1 を表示してチャンネル名の絞り込み条件を入力して submit を押してもらう
- 入力条件に合致するパブリックチャンネルの一覧を取得
- 実行者A の参加しているパブリックチャンネルの一覧を取得
- それぞれの結果から 実行者A が未参加のチャンネル一覧と参加済みの一覧を抽出
- ワークフローボットの参加しているパブリックチャンネル一覧を取得
- 4 と 5 の結果から「今回招待対象だけどワークフローボットが未参加のチャンネル」へ参加させる[9]
- フォーム2 に 4の結果を表示して参加可能なチャンネルが 1件以上あれば submit を表示、0件なら close のみ表示
- submit だった場合 実行ユーザーをそのチャンネルへ一括で招待する
- フォーム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 ライフをお送りください🥂
記事内に記載できなかった参考記事
- https://tech.travelbook.co.jp/posts/slack-deno-api/
- https://zenn.dev/razokulover/articles/3413919157af7f
- https://zenn.dev/team_soda/articles/7aa773880abce2
We are hiring!!
最後に、IVRy では一緒に働く仲間を絶賛募集中です。
今の所 順調に成長してきていますが、今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!
カジュアルに話を聞きたいという方は私の Meety から面談を申し込んでいただければ色々お話します。
-
ということで執筆時点での私の次世代プラットフォーム歴は 1週間なので間違い・勘違いもそれなりにあるかもしれませんがご容赦くださいmm ↩︎
-
とはいえ読み進めるだけでもある程度理解できるように私の理解の範囲内でコマンド結果も含めて詳しく書いたつもりです。 ↩︎
-
現状、ワークフロービルダーではできないことも Slack CLI ではできます。今回は GUI で完結できるものだったので対応関係を見るのに良さそうと思いワークフロービルダーを使いました。将来的には Slack CLI で作成したカスタムファンクション等もワークフロービルダーで使えるようにする方針らしいです。 ↩︎
-
Slack の権限が複雑で私が理解できていないだけかもしれません。 ↩︎
-
「絵文字リアクションが使用された時」「誰かがチャンネルに参加した時」しか見当たりませんでした。 ↩︎
-
既にサードパーティ製のアプリで実現している方も多いかと思いますが... ↩︎
-
試したことはないですが、デフォルトチャンネルからは抜けられないらしく退出した瞬間に強制召喚されるらしいです。私は IVRy の基本パブリックチャンネルでやりとりし、個人チャンネルや趣味のチャンネルへの入退室自由な雰囲気がとても好きなのでそれだけはやめて欲しいという思いがありました。 ↩︎
-
そういった意図から表示するフォームには招待されるユーザー指定項目は設けず、実行者に対する処理に限定しました。似た名前の人を間違えて指定してしまった場合など 100以上のチャンネルから 1つずつ選んで抜けるのは悲劇ですからね... ↩︎
-
この処理に関しては 8 の実行前であればいつでも構いません。事前に全チャンネルに入れておけばいらぬ心配だと思います。 ↩︎
Discussion