12
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Slack 次世代プラットフォーム機能を少しずつ試す - トリガー設定ワークフロー編

Last updated at Posted at 2022-12-20

こんにちは、Slack の公式 SDK 開発と日本の Developer Relations を担当している瀬良 (@seratch) と申します :wave:

この記事は 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.historyconversations.repliesreactions.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 の解説シリーズを改めて読み返すか、以下の英語のページをご参照ください。

それでは!

12
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?