こんにちは、Slack の公式 SDK 開発と日本の Developer Relations を担当している瀬良 (@seratch) と申します
この記事は Slack の次世代プラットフォーム機能を少しずつ試しながら、ゆっくりと理解していくシリーズの記事です。
「次世代プラットフォーム機能って何?」という方は、以下の記事で詳しく解説しましたので、まずはそちらをお読みください。
今回の記事では、トリガーをファンクションの中の API コールによって管理する方法をご紹介します。
ソースコードを用意して slack triggers create --trigger-def ./triggers/some_trigger.ts
のようにコマンドでトリガーを生成する方法は、こちらの記事で解説しました。
このやり方は多くの場合でうまく機能しますが、要件によっては以下のような課題が顕在化することがあります。
-
channel_ids
を指定する必要があるイベントトリガーの場合、チャンネル ID のリストを変更するたびに、ソースコードを書き換えた上でslack triggers delete
した上でslack triggers create
という手順で再作成が必要となる。 - 上記の手順だとエンドユーザーが気軽に設定を変更できず、ワークフローの管理権限を持つ開発者がコマンドライン操作 or 自動化したプロセスを実行する必要がある。
- ソースコードに
channel_ids
などをハードコードするため、ソースコードの再利用性が低い。似ているが、対象のチャンネルが異なるトリガーが複数ある場合、そのトリガーごとにソースコードを管理する必要がある。
今回の記事では、上記の問題を解消するために、以下のようなサンプル実装をご紹介します。
- メインのワークフローとは別に、設定用のワークフローを作り、その設定用ワークフローはトリガーを API コールによって設定するファンクションを実行する
- 以下のような二種類の設定ワークフローを試す
- ウェブフックトリガー経由で渡された情報を使ってトリガーを設定する
- リンクトリガーから起動してフォームで受け取った情報を使ってトリガーを設定する
それでは、早速始めていきましょう。
プロジェクトを作成
いつものようにブランクプロジェクトを作成してゼロからコードを足していきましょう。slack create
コマンドを実行して、選択肢から「Blank Project」を選択してください。作成したプロジェクトの構成は以下の通りです。
$ tree
.
├── LICENSE
├── README.md
├── assets
│ └── default_new_app_icon.png
├── deno.jsonc
├── import_map.json
├── manifest.ts
└── slack.json
トリガーを設定するファンクションを追加
今回は二種類のファンクションを作成しますが、トリガーに関する処理とチャンネルへの参加は流用できる処理ですので、以下の二つの共通ファイルを先に作ります。
まず、トリガー管理の種処理である関数を manage_triggers.ts
として以下の内容で保存してください。
import { SlackAPIClient } from "deno-slack-api/types.ts";
const triggerEventType = "slack#/events/reaction_added";
const triggerName = "reaction_added event trigger";
const triggerInputs = {
userId: { value: "{{data.user_id}}" },
channelId: { value: "{{data.channel_id}}" },
messageTs: { value: "{{data.message_ts}}" },
reaction: { value: "{{data.reaction}}" },
};
// 設定対象のトリガーがすでに存在するかチェックして、存在する場合はそのメタデータを返す
// すでに同じ event_type でトリガーが複数作成されている状況のハンドリングはサポートしない
export async function findTriggerToUpdate(
client: SlackAPIClient,
workflowCallbackId: string,
): Promise<Record<string, string> | undefined> {
// このアプリからアクセス可能なトリガーの一覧を取得する
const listResponse = await client.workflows.triggers.list({ is_owner: true });
// 対象のトリガーが含まれているか探して見つけたらそのままそれを返す
// この実装では、複数存在している場合は想定しない
if (listResponse && listResponse.triggers) {
for (const trigger of listResponse.triggers) {
if (
trigger.workflow.callback_id === workflowCallbackId &&
trigger.event_type === triggerEventType
) {
return trigger;
}
}
}
// 対象のトリガーはまだ作成されていなかったので新規作成が必要
return undefined;
}
// 対象のトリガーを作成または更新する
// このメソッドの処理は atomic ではないため、同時に作成の処理が実行されると二つトリガーが作成される可能性がある
// また、同時に更新の処理が実行された場合も、後勝ちで更新される
export async function createOrUpdateTrigger(
client: SlackAPIClient,
workflowCallbackId: string,
channelIds: string[],
triggerId?: string,
): Promise<void> {
// 型制約がハードコーディングを要求するためやむを得ず any にキャストする
// deno-lint-ignore no-explicit-any
const channel_ids = channelIds as any;
if (triggerId) {
// 対象の ID のトリガーの設定内容を更新する
const update = await client.workflows.triggers.update({
trigger_id: triggerId,
type: "event",
name: triggerName,
workflow: `#/workflows/${workflowCallbackId}`,
event: { event_type: triggerEventType, channel_ids },
inputs: triggerInputs,
});
if (update.error) {
const error = `Failed to update a trigger! (response: ${
JSON.stringify(update)
})`;
throw new Error(error);
}
console.log(`The trigger updated: ${JSON.stringify(update)}`);
} else {
// 新しくトリガーを作成する
const creation = await client.workflows.triggers.create({
type: "event",
name: triggerName,
workflow: `#/workflows/${workflowCallbackId}`,
event: { event_type: triggerEventType, channel_ids },
inputs: triggerInputs,
});
if (creation.error) {
const error = `Failed to create a trigger! (response: ${
JSON.stringify(creation)
})`;
throw new Error(error);
}
console.log(`A new trigger created: ${JSON.stringify(creation)}`);
}
}
次に、これは必須ではありませんが、自動的にこのアプリのボットユーザーを指定されたチャンネルすべてに参加させたい場合は、join_channels.ts
として以下のコードを保存します。
import { SlackAPIClient } from "deno-slack-api/types.ts";
// 渡されたすべてのチャンネルに参加する
// すでに参加している場合は API から warning が返されるが、エラーにはならない
export async function joinAllChannels(
client: SlackAPIClient,
channelIds: string[],
): Promise<string | undefined> {
const futures = channelIds.map((c) => _joinChannel(client, c));
const results = (await Promise.all(futures)).filter((r) => r !== undefined);
if (results.length > 0) {
throw new Error(results[0]);
}
return undefined;
}
async function _joinChannel(
client: SlackAPIClient,
channelId: string,
): Promise<string | undefined> {
const response = await client.conversations.join({ channel: channelId });
if (response.error) {
const error = `Failed to join <#${channelId}> due to ${response.error}`;
console.log(error);
return error;
}
}
ボットユーザーを参加させる理由は、参加していないと多くの API コールが行えないためです。例えば conversations.history
、conversations.replies
、reactions.get
などの基本的な API の呼び出しが行えません。この状態だとチャンネルに関連した処理で、あまり意味のある処理を行うことはできないでしょう。
トリガーを設定される側のイベントワークフローを作成
次に、設定用ワークフローによって "reaction_added" イベントトリガーを設定してもらう方のワークフローを作成します。main_workflow.ts
という名前で以下の内容を保存してください。
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
export const workflow = DefineWorkflow({
callback_id: "main_event_workflow",
title: "Main event workflow",
input_parameters: {
properties: {
// manage_triggers.ts の triggerInputs と一致している必要がある
userId: { type: Schema.slack.types.user_id },
channelId: { type: Schema.slack.types.channel_id },
messageTs: { type: Schema.types.string },
reaction: { type: Schema.types.string },
},
required: ["userId", "channelId", "messageTs", "reaction"],
},
});
// エフェメラルメッセージを送信する
workflow.addStep(Schema.slack.functions.SendEphemeralMessage, {
user_id: workflow.inputs.userId,
channel_id: workflow.inputs.channelId,
message: `Thanks for adding :${workflow.inputs.reaction}:!`,
});
今までの記事では毎回ワークフローにコードでトリガーを設定していましたが、今回はそれをせずに次のセクションで紹介する別のワークフローの中の API 呼び出しによって作成・更新してもらいます。
ウェブフックトリガーを使った設定用ワークフローを追加
さて、いよいよ設定用ワークフローを作っていきます。まず import するファンクションを定義します。configure.ts
という名前で以下を保存してください。
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
import {
createOrUpdateTrigger,
findTriggerToUpdate,
} from "./manage_triggers.ts";
import { joinAllChannels } from "./join_channels.ts";
export const def = DefineFunction({
callback_id: "configure",
title: "Configure a trigger",
source_file: "configure.ts",
input_parameters: {
properties: {
workflowCallbackId: { type: Schema.types.string },
channelIds: {
type: Schema.types.array,
items: { type: Schema.slack.types.channel_id },
},
},
required: ["workflowCallbackId", "channelIds"],
},
output_parameters: { properties: {}, required: [] },
});
export default SlackFunction(def, async ({ inputs, client }) => {
try {
const existingTrigger = await findTriggerToUpdate(
client,
inputs.workflowCallbackId,
);
await createOrUpdateTrigger(
client,
inputs.workflowCallbackId,
inputs.channelIds,
existingTrigger?.id,
);
} catch (e) {
const error = `Failed to create/update a trigger due to ${e}.`;
return { error };
}
// チャンネルに参加しなくて良い場合はこのパートは削除してもよい
const failure = await joinAllChannels(client, inputs.channelIds);
if (failure) {
const error = `Failed to join channels due to ${failure}.`;
return { error };
} else {
return { outputs: {} };
}
});
次に設定用ワークフローとそのトリガーを webhook_workflow.ts
というファイル名で作成します。
// -------------------------
// ワークフロー定義
// -------------------------
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
export const workflow = DefineWorkflow({
callback_id: "webhook_configurator",
title: "Webhook Configurator",
input_parameters: {
properties: {
channel_ids: {
type: Schema.types.array,
items: { type: Schema.slack.types.channel_id },
},
},
required: ["channel_ids"],
},
});
import { def as Configure } from "./configure.ts";
import { workflow as MainWorkflow } from "./main_workflow.ts";
workflow.addStep(Configure, {
// ここには main_workflow.ts の方の callback_id を指定する
// 間違えてこの "設定用" ワークフローの方を設定しないよう注意
workflowCallbackId: MainWorkflow.definition.callback_id,
channelIds: workflow.inputs.channel_ids,
});
// -------------------------
// トリガー定義
// -------------------------
import { Trigger } from "deno-slack-api/types.ts";
const trigger: Trigger<typeof workflow.definition> = {
type: "webhook",
name: "Webhook Configurator Trigger",
// こちらには設定用ワークフローを設定する
workflow: `#/workflows/${workflow.definition.callback_id}`,
inputs: { "channel_ids": { "value": "{{data.channel_ids}}" } },
};
// トリガーの作成には `slack triggers create --trigger-def [ファイルパス]` を実行する
// Trigger 形の定義オブジェクトを export default さえしていれば
// そのソースファイルを使用できる
export default trigger;
そして manifest.ts
に作成した二つのワークフローを登録します。
import { Manifest } from "deno-slack-sdk/mod.ts";
import { workflow as ConfiguratorWorkflow } from "./webhook_configurator.ts";
import { workflow as MainWorkflow } from "./main_workflow.ts";
export default Manifest({
name: "vibrant-orca-513",
description: "Configurator Demo",
icon: "assets/default_new_app_icon.png",
workflows: [ConfiguratorWorkflow, MainWorkflow],
outgoingDomains: [],
botScopes: [
"commands",
// SendEphemeral の実行に必要
"chat:write",
"chat:write.public",
// main_workflow.ts の実行に必要
"reactions:read",
// configure.ts の実行に必要
"triggers:read",
"triggers:write",
"channels:join",
],
});
slack run
でアプリを起動してみて、エラーが発生していないことを確認してください。
ウェブフックトリガーを作成してワークフローを試す
slack triggers create --trigger-def ./webhook_configurator.ts
を実行して Webhook URL を発行します。
$ slack triggers create --trigger-def ./webhook_configurator.ts
? Choose an app seratch (dev) T03E94MJU
vibrant-orca-513 (dev) A04FRL4323G
⚡ Trigger created
Trigger ID: Ft04FY7H1AM9
Trigger Type: webhook
Trigger Name: Webhook Configurator Trigger
Webhook URL: https://hooks.slack.com/triggers/T03E94***/***/***
この URL に対して curl コマンドでリクエストを送ります。こちらの手順を参考にチャンネルの ID を入手して実行してください。
この curl リクエストを送る前に、別のターミナルウィンドウで slack run
を起動しておくことを忘れずに!
# URL とチャンネル ID はご自身の正しいものを設定してください
$ curl -XPOST \
https://hooks.slack.com/triggers/T03E94***/***/*** \
-d'{"channel_ids": ["C03E94MKS"]}'
{"ok":true}%
slack run
の方にエラーが出ていなければ問題ないでしょう。
$ slack run
? Choose a workspace seratch T03E94MJU
vibrant-orca-513 A04FRL4323G
Updating dev app install for workspace "Acme Corp"
⚠️ Outgoing domains
No allowed outgoing domains are configured
If your function makes network requests, you will need to allow the outgoing domains
Learn more about upcoming changes to outgoing domains: https://api.slack.com/future/changelog
✨ seratch of Acme Corp
Connected, awaiting events
2022-12-20 14:14:29 [info] [Fn04GN1S5CP2] (Trace=Tr04FRNYD1QW) Function execution started for workflow function 'Webhook Configurator'
2022-12-20 14:14:29 [info] [Wf04FY7BP8M9] (Trace=Tr04FHQLH3K9) Execution started for workflow 'Webhook Configurator'
2022-12-20 14:14:30 [info] [Wf04FY7BP8M9] (Trace=Tr04FHQLH3K9) Executing workflow step 1 of 1
2022-12-20 14:14:30 [info] [Fn04FVCA06N9] (Trace=Tr04FHQLH3K9) Function execution started for app function 'Configure a trigger'
A new trigger created: {"ok":true,"trigger":{ ... }}
2022-12-20 14:14:32 [info] [Fn04FVCA06N9] (Trace=Tr04FHQLH3K9) Function execution completed for function 'Configure a trigger'
2022-12-20 14:14:32 [info] [Wf04FY7BP8M9] (Trace=Tr04FHQLH3K9) Execution completed for workflow step 'Configure a trigger'
2022-12-20 14:14:33 [info] [Fn04GN1S5CP2] (Trace=Tr04FRNYD1QW) Function execution completed for function 'Webhook Configurator'
2022-12-20 14:14:33 [info] [Wf04FY7BP8M9] (Trace=Tr04FHQLH3K9) Execution completed for workflow 'Webhook Configurator'
ワークフローが正しく動いているか確認してみましょう。以下のように何かリアクションをつけて、
それに対してエフェメラルメッセージが返ってくるなら正しく動作しています。
リンクトリガーとモーダルを使った設定用ワークフローを追加
次は、もう少しユーザーフレンドリーな方法を実装してみましょう。モーダルを使ったバージョンのファンクションを追加して、それを使うワークフローとリンクトリガーを定義します。
モーダルを使うファンクションは configure_with_modal.ts
として以下の内容を保存します。
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
import {
createOrUpdateTrigger,
findTriggerToUpdate,
} from "./manage_triggers.ts";
import { joinAllChannels } from "./join_channels.ts";
export const def = DefineFunction({
callback_id: "configure_with_modal",
title: "Configure a trigger using a modal",
source_file: "configure_with_modal.ts",
input_parameters: {
properties: {
interactivity: { type: Schema.slack.types.interactivity },
workflowCallbackId: { type: Schema.types.string },
},
required: ["interactivity", "workflowCallbackId"],
},
output_parameters: { properties: {}, required: [] },
});
export default SlackFunction(def, async ({ inputs, client }) => {
const existingTrigger = await findTriggerToUpdate(
client,
inputs.workflowCallbackId,
);
const channelIds = existingTrigger?.channel_ids != undefined
? existingTrigger.channel_ids
: [];
const response = await client.views.open({
interactivity_pointer: inputs.interactivity.interactivity_pointer,
view: {
"type": "modal",
"callback_id": "configure-workflow",
"title": { "type": "plain_text", "text": "My App" },
"submit": { "type": "plain_text", "text": "Confirm" },
"close": { "type": "plain_text", "text": "Close" },
"blocks": [
{
"type": "input",
"block_id": "channels",
"element": {
"type": "multi_channels_select",
"initial_channels": channelIds,
"action_id": "action",
},
"label": {
"type": "plain_text",
"text": "Channels to enable the main workflow",
},
},
],
},
});
if (!response.ok) {
const error =
`Failed to open a modal in the configurator workflow. Contact the app maintainers with the following information - (error: ${response.error})`;
return { error };
}
return {
// このファンクションを終了させないために false を返す
completed: false,
};
})
.addViewSubmissionHandler(
["configure-workflow"],
async ({ inputs, client, view }) => {
const channelIds = view.state.values.channels.action.selected_channels;
try {
await createOrUpdateTrigger(
client,
inputs.workflowCallbackId,
channelIds,
(await findTriggerToUpdate(client, inputs.workflowCallbackId))?.id,
);
} catch (e) {
const error = `Failed to create/update a trigger due to ${e}.`;
return { error };
}
// チャンネルに参加しなくて良い場合はこのパートは削除してもよい
const failure = await joinAllChannels(client, channelIds);
if (failure) {
const error = `Failed to join channels due to ${failure}.`;
return { error };
}
return {
response_action: "update",
view: {
"type": "modal",
"callback_id": "completion",
"title": { "type": "plain_text", "text": "My App" },
"close": { "type": "plain_text", "text": "Close" },
"blocks": [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text":
"*You're all set!*\n\nThe main workflow is now available for the channels :white_check_mark:",
},
},
],
},
};
},
);
次にワークフローとトリガーを定義します。
// ----------------
// ワークフロー定義
// ----------------
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
export const workflow = DefineWorkflow({
callback_id: "modal-configurator",
title: "Modal Configurator",
input_parameters: {
properties: { interactivity: { type: Schema.slack.types.interactivity } },
required: ["interactivity"],
},
});
// ここにモーダルを使った自前のファンクションのステップを追加
import { def as ConfigureWithModal } from "./configure_with_modal.ts";
import { workflow as MainWorkflow } from "./main_workflow.ts";
workflow.addStep(ConfigureWithModal, {
interactivity: workflow.inputs.interactivity,
workflowCallbackId: MainWorkflow.definition.callback_id,
});
// ----------------
// トリガー定義
// ----------------
import { Trigger } from "deno-slack-api/types.ts";
const trigger: Trigger<typeof workflow.definition> = {
type: "shortcut",
name: "Modal Configurator Trigger",
workflow: `#/workflows/${workflow.definition.callback_id}`,
inputs: {
// モーダルを使ったインタラクションには interactivity が必須
// この記事投稿時点で interactivity を提供できるのはリンクトリガーのみ
interactivity: { value: "{{data.interactivity}}" },
},
};
export default trigger;
そして、 manifest.ts
にこのワークフローを追加するのを忘れずに。
import { Manifest } from "deno-slack-sdk/mod.ts";
import { workflow as ConfiguratorWorkflow } from "./webhook_configurator.ts";
import { workflow as ModalConfiguatorWorkflow } from "./modal_configurator.ts";
import { workflow as MainWorkflow } from "./main_workflow.ts";
export default Manifest({
name: "vibrant-orca-513",
description: "Configurator Demo",
icon: "assets/default_new_app_icon.png",
workflows: [ConfiguratorWorkflow, ModalConfiguatorWorkflow, MainWorkflow],
outgoingDomains: [],
botScopes: [
"commands",
// SendEphemeral の実行に必要
"chat:write",
"chat:write.public",
// main_workflow.ts の実行に必要
"reactions:read",
// configure.ts の実行に必要
"triggers:read",
"triggers:write",
"channels:join",
],
});
slack triggers create --trigger-def modal_configurator.ts
でリンクトリガーを作成して、実行してみてください。以下のようにモーダルの UI 上で簡単にチャンネルを設定できるようになります。
初回起動で先ほどウェブフックトリガーに渡したチャンネルが反映されていることにも注目してください。このモーダルでの方法と先ほどのウェブフックでの方法は同じトリガーを参照しているのです。
コードにも注意点をコメントで書いてありますが、このモーダルでの設定は同時に複数人が実行したときにデータの整合性は保証されません。更新の場合は常に後勝ちで更新されます。また、万が一、トリガーの初回作成時に全く同タイミングで実行されると(ドンピシャのタイミングでないと起きませんが・・)、二件以上のデータが作成され、後者は設定不能となってしまう可能性もあります。二件データができてしまった場合は slack triggers delete
でどちらかを削除してください。
このような混乱のリスクを軽減するには、この設定画面への導線は一部のメンバーに限定するとよいでしょう。この記事投稿時点のベータ版では、リンクトリガーの配置はパブリックチャンネルのみ可となっていますが、正式リリース時にはプラベートチャンネルもサポートされる予定です。
また、ここでご紹介したモーダルの実装方法の基本については、別の記事で解説していますので、そちらもぜひ読んでみてください。
終わりに
いかがだったでしょうか?別途、設定用のワークフローを実装する必要があるのは少し手間ですが、その分工夫次第ではワークフローの運用をより快適にできるかもしれません。
今回の記事の後半でご紹介したモーダルによる設定方法は、以下の公式サンプルアプリでの実装を簡略化したものです。Slack メッセージを翻訳してくれる便利なアプリですので、ぜひ試してみてください。
また、トリガーについての詳細な情報は、この Qiita の解説シリーズを改めて読み返すか、以下の英語のページをご参照ください。
それでは!