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

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

この記事は [Slack の次世代プラットフォーム機能を少しずつ試しながら、ゆっくりと理解していくシリーズ](https://qiita.com/seratch/items/b1449132add003e61de6)の記事です。

「次世代プラットフォーム機能って何？」という方は、以下の記事で詳しく解説しましたので、まずはそちらをお読みください。

https://qiita.com/seratch/items/ecc16b845483c9d6f9e1

今回の記事では、トリガーをファンクションの中の API コールによって管理する方法をご紹介します。

ソースコードを用意して `slack triggers create --trigger-def ./triggers/some_trigger.ts` のようにコマンドでトリガーを生成する方法は、こちらの記事で解説しました。

https://qiita.com/seratch/items/8c91bb367bb942dc0ffe

このやり方は多くの場合でうまく機能しますが、要件によっては以下のような課題が顕在化することがあります。

* `channel_ids` を指定する必要があるイベントトリガーの場合、チャンネル ID のリストを変更するたびに、ソースコードを書き換えた上で `slack triggers delete` した上で `slack triggers create` という手順で再作成が必要となる。
* 上記の手順だとエンドユーザーが気軽に設定を変更できず、ワークフローの管理権限を持つ開発者がコマンドライン操作 or 自動化したプロセスを実行する必要がある。
* ソースコードに `channel_ids` などをハードコードするため、ソースコードの再利用性が低い。似ているが、対象のチャンネルが異なるトリガーが複数ある場合、そのトリガーごとにソースコードを管理する必要がある。

今回の記事では、上記の問題を解消するために、以下のようなサンプル実装をご紹介します。

* メインのワークフローとは別に、設定用のワークフローを作り、その設定用ワークフローはトリガーを API コールによって設定するファンクションを実行する
* 以下のような二種類の設定ワークフローを試す
    * ウェブフックトリガー経由で渡された情報を使ってトリガーを設定する
    * リンクトリガーから起動してフォームで受け取った情報を使ってトリガーを設定する

それでは、早速始めていきましょう。

### プロジェクトを作成

いつものようにブランクプロジェクトを作成してゼロからコードを足していきましょう。`slack create` コマンドを実行して、選択肢から「Blank Project」を選択してください。作成したプロジェクトの構成は以下の通りです。

```bash
$ tree
.
├── LICENSE
├── README.md
├── assets
│   └── default_new_app_icon.png
├── deno.jsonc
├── import_map.json
├── manifest.ts
└── slack.json
```

### トリガーを設定するファンクションを追加

今回は二種類のファンクションを作成しますが、トリガーに関する処理とチャンネルへの参加は流用できる処理ですので、以下の二つの共通ファイルを先に作ります。

まず、トリガー管理の種処理である関数を `manage_triggers.ts` として以下の内容で保存してください。

```typescript
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` として以下のコードを保存します。

```typescript
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` という名前で以下の内容を保存してください。

```typescript
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` という名前で以下を保存してください。

```typescript
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` というファイル名で作成します。

```typescript
// -------------------------
// ワークフロー定義
// -------------------------
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` に作成した二つのワークフローを登録します。

```typescript
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 コマンドでリクエストを送ります。[こちらの手順](https://qiita.com/seratch/items/8c91bb367bb942dc0ffe#%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB-id-%E3%82%92%E5%85%A5%E6%89%8B%E3%81%97%E3%81%A6-hello_worldts-%E3%82%92%E4%B8%80%E8%A1%8C%E3%81%A0%E3%81%91%E6%9B%B8%E3%81%8D%E6%8F%9B%E3%81%88%E3%82%8B)を参考にチャンネルの ID を入手して実行してください。

この curl リクエストを送る前に、**別のターミナルウィンドウで `slack run` を起動しておくことを忘れずに！**

```bash
# 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'
```

ワークフローが正しく動いているか確認してみましょう。以下のように何かリアクションをつけて、

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/bb3c42ae-0edc-e265-4dcc-f16faf7a0740.gif" width=600 />

それに対してエフェメラルメッセージが返ってくるなら正しく動作しています。

### リンクトリガーとモーダルを使った設定用ワークフローを追加

次は、もう少しユーザーフレンドリーな方法を実装してみましょう。モーダルを使ったバージョンのファンクションを追加して、それを使うワークフローとリンクトリガーを定義します。

モーダルを使うファンクションは `configure_with_modal.ts` として以下の内容を保存します。

```typescript
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:",
              },
            },
          ],
        },
      };
    },
  );
```

次にワークフローとトリガーを定義します。

```typescript
// ----------------
// ワークフロー定義
// ----------------

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` にこのワークフローを追加するのを忘れずに。

```typescript
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 上で簡単にチャンネルを設定できるようになります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/f2b3ff8c-04eb-86db-680c-9ef99e3ffa89.gif" width=600 />

初回起動で先ほどウェブフックトリガーに渡したチャンネルが反映されていることにも注目してください。このモーダルでの方法と先ほどのウェブフックでの方法は同じトリガーを参照しているのです。

コードにも注意点をコメントで書いてありますが、このモーダルでの設定は同時に複数人が実行したときにデータの整合性は保証されません。更新の場合は常に後勝ちで更新されます。また、万が一、トリガーの初回作成時に全く同タイミングで実行されると（ドンピシャのタイミングでないと起きませんが・・）、二件以上のデータが作成され、後者は設定不能となってしまう可能性もあります。二件データができてしまった場合は `slack triggers delete` でどちらかを削除してください。

このような混乱のリスクを軽減するには、この設定画面への導線は一部のメンバーに限定するとよいでしょう。この記事投稿時点のベータ版では、リンクトリガーの配置はパブリックチャンネルのみ可となっていますが、正式リリース時にはプラベートチャンネルもサポートされる予定です。

また、ここでご紹介したモーダルの実装方法の基本については、別の記事で解説していますので、そちらもぜひ読んでみてください。

https://qiita.com/seratch/items/3258a0d2b40d39f5435f

### 終わりに

いかがだったでしょうか？別途、設定用のワークフローを実装する必要があるのは少し手間ですが、その分工夫次第ではワークフローの運用をより快適にできるかもしれません。

今回の記事の後半でご紹介したモーダルによる設定方法は、以下の公式サンプルアプリでの実装を簡略化したものです。Slack メッセージを翻訳してくれる便利なアプリですので、ぜひ試してみてください。

https://github.com/slack-samples/deno-message-translator

また、トリガーについての詳細な情報は、[この Qiita の解説シリーズ](https://qiita.com/seratch/items/b1449132add003e61de6)を改めて読み返すか、以下の英語のページをご参照ください。

https://api.slack.com/future/triggers

それでは！