Slack 次世代プラットフォーム機能を少しずつ試す - app_mentioned イベントトリガー編

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

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

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

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

この記事では、[イベントトリガー（Event Trigger）](https://api.slack.com/future/triggers/event)のうち、アプリのボットユーザーがメンションされたときに発生する "app_mentioned" イベントを使ったワークフロー実行の方法をご紹介します。

### ブランクプロジェクトを作成

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

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

今回はトリガーがメインの記事ですので、ワークフローは非常にシンプルなものにしましょう。

### ソースコードでトリガーを作る

以下の内容を `example.ts` として保存してください。

```typescript
// -------------------------
// ワークフロー定義
// -------------------------
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";

export const workflow = DefineWorkflow({
  callback_id: "example-workflow",
  title: "Example Workflow",
  input_parameters: {
    properties: {
      channel_id: { type: Schema.slack.types.channel_id },
      channel_type: { type: Schema.types.string },
      channel_name: { type: Schema.types.string },
      user_id: { type: Schema.slack.types.user_id },
      text: { type: Schema.types.string },
    },
    required: ["channel_id", "channel_type", "channel_name", "user_id", "text"],
  },
});

workflow.addStep(Schema.slack.functions.SendMessage, {
  channel_id: workflow.inputs.channel_id,
  message: `Hey <@${workflow.inputs.user_id}>, what's up?`,
});

// -------------------------
// トリガー定義
// -------------------------
import { Trigger } from "deno-slack-api/types.ts";

const trigger: Trigger<typeof workflow.definition> = {
  type: "event",
  name: "Trigger the example workflow",
  workflow: `#/workflows/${workflow.definition.callback_id}`,
  event: {
    event_type: "slack#/events/app_mentioned",
    channel_ids: ["C04FB5UF1C2"], // TODO: このリストを実際のチャンネル ID に書き換える
  },
  inputs: {
    channel_id: { value: "{{data.channel_id}}" },
    channel_type: { value: "{{data.channel_type}}" },
    channel_name: { value: "{{data.channel_name}}" },
    user_id: { value: "{{data.user_id}}" },
    text: { value: "{{data.text}}" },
  },
};

export default trigger;
```

`channel_ids` の箇所は書き換えが必要です。以下の記事の手順を参考に、正しいチャンネルの ID を指定してください。

https://qiita.com/seratch/items/a4770243f3799b553e9c#%E3%82%BD%E3%83%BC%E3%82%B9%E3%82%B3%E3%83%BC%E3%83%89%E3%81%A7%E3%83%88%E3%83%AA%E3%82%AC%E3%83%BC%E3%82%92%E4%BD%9C%E3%82%8B

そして `manifest.ts` にこのワークフローを追加します。

```typescript
import { Manifest } from "deno-slack-sdk/mod.ts";
import { workflow as ExampleWorkflow } from "./example.ts";

export default Manifest({
  name: "wizardly-squirrel-338",
  description: "Demo app",
  icon: "assets/default_new_app_icon.png",
  workflows: [ExampleWorkflow],
  outgoingDomains: [],
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
    "app_mentions:read", // trigger に必要
  ],
});
```

なお、この記事執筆時点でこの次世代プラットフォームはまだベータ版の段階で、イベントトリガーはパブリックチャンネルしかサポートしておりません。正式リリース前にこの記事を読まれている方は、パブリックチャンネルでお試しください。

ということで、今回は特にコードの変更は必要なく、ワークフロー実行の準備が完了しました。

`slack triggers create --trigger-def ./example.ts` を実行します。選択肢から `(dev)` という接尾辞がついている方を選択してください。

```bash
$ slack triggers create --trigger-def ./example.ts
? Choose an app  seratch (dev)  T03E*****
   wizardly-squirrel-338 (dev) A04FNE*****

⚡ Trigger created
   Trigger ID:   Ft04EJ8*****
   Trigger Type: event
   Trigger Name: Trigger the example workflow
```

トリガーが作成されたら、早速 `slack run` を実行してアプリがイベントを受けられるようにしましょう。そして、指定したチャンネルにボットを invite してメンションしてみてください。以下のように返信が投稿されれば成功です。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/b4afd5e5-5886-1588-b5d2-a4c7522115b7.png" width=400 />

・・・これで終われば簡単でよかったのですが、実はこの記事投稿時点でこの "app_mentioned" イベントトリガーには「**有効にしたチャンネルでどんなアプリをメンションしたときでもトリガーが実行されてしまう**」という（致命的な）不具合が存在しています（2022 年 12 月時点での状況です）。

具体的には、以下のスクリーンショットのように Google Calendar アプリのボットをメンションしてもトリガーが実行されてしまうのです・・・

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/233055e3-8724-c1d7-3383-f56e268807d8.png" width=400 />

これは開発チームに報告済で、重大なバグであると認識されており、正式リリース時までには修正される見込みです。

**（2023/02/01 追記）本日よりこの不具合は解消されております。以下の処理は全て不要となりました。**

ですが、それまでは、トリガーかファンクション側で自分のアプリのボットがメンションされたかどうかを判定する必要があります。それぞれコード例とともにご紹介します。

### ファンクション内で自アプリのメンションかを判定する

まずは、ファンション側で判定する方法をご紹介します。

[`auth.test` API](https://api.slack.com/methods/auth.test) を使って、自アプリのボットユーザー ID を取得して、それがメッセージのメンションに含まれているかを判定します。

まず、以下の内容を `check_mentioned_app.ts` として保存します。

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

export const def = DefineFunction({
  callback_id: "check_mentioned_app",
  title: "Check the mentioned app",
  source_file: "check_mentioned_app.ts",
  input_parameters: {
    properties: { text: { type: Schema.types.string } },
    required: ["text"],
  },
  output_parameters: {
    properties: {
      is_this_app_mentioned: { type: Schema.types.boolean },
    },
    required: ["is_this_app_mentioned"],
  },
});

export default SlackFunction(def, async ({
  inputs,
  client,
}) => {
  const authTest = await client.auth.test({});
  if (authTest.error) {
    const error = `Failed to call auth.test API: ${JSON.stringify(authTest)}`;
    return { error };
  }
  const botUserId = authTest.user_id;
  const meMentioned = inputs.text.includes(`<@${botUserId}>`);
  return {
    outputs: {
      is_this_app_mentioned: meMentioned,
    },
  };
});
```

この記事投稿時点でワークフロー側で if/else 分岐処理を行うことができませんので、判定結果を自前のファンクションに渡して処理をスキップするしかありません。

この事情から、今回は標準ファンクションの SendMessage の代わりに `my_send_message.ts` を以下の内容で保存します。`allowed` という真偽値を受け取ってメッセージを投稿するか判定しています。

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

export const def = DefineFunction({
  callback_id: "my-send-message",
  title: "My SendMessage",
  source_file: "my_send_message.ts",
  input_parameters: {
    properties: {
      channel_id: { type: Schema.slack.types.channel_id },
      message: { type: Schema.types.string },
      // これを追加
      allowed: { type: Schema.types.boolean },
    },
    required: ["channel_id", "message", "allowed"],
  },
  output_parameters: {
    properties: { ts: { type: Schema.types.string } },
    required: [],
  },
});

export default SlackFunction(def, async ({ inputs, client }) => {
  // スキップ対象の場合はメッセージを送信せず終了
  if (!inputs.allowed) {
    return { outputs: {} };
  }
  const newMessage = await client.chat.postMessage({
    channel: inputs.channel_id,
    text: inputs.message,
  });
  return { outputs: { ts: newMessage.ts } };
});
```

準備が整いましたので、SendMessage を `example.ts` のワークフローから削除して、代わりに上の二つのファンクションをこのように追加してください。

```typescript
// workflow.addStep(Schema.slack.functions.SendMessage, {
//   channel_id: workflow.inputs.channel_id,
//   message: `Hey <@${workflow.inputs.user_id}>, what's up?`,
// });

import { def as checkMentionedApp } from "./check_mentioned_app.ts";
const checkMentionedAppStep = workflow.addStep(checkMentionedApp, {
  text: workflow.inputs.text,
});

import { def as mySendMessage } from "./my_send_message.ts";
workflow.addStep(mySendMessage, {
  allowed: checkMentionedAppStep.outputs.is_this_app_mentioned,
  channel_id: workflow.inputs.channel_id,
  message: `Hey <@${workflow.inputs.user_id}>, what's up?`,
});
```

こうすることで Google Calendar アプリへのメンションに反応してしまう・・・という問題を回避することができました。

なお、繰り返しとなりますが、**これはプラットフォーム側のバグであり、正式リリース時には修正されます**。ベータ期間とはいえ、お手数をおかけしますこと、大変申し訳ありません。

### トリガー側で自アプリのメンションかを判定する

ただ、ワークフローに複数のファンクションがある場合は毎回このような真偽値の引き回しをするのは面倒ですし、そもそも不要なワークフロー起動はトリガーの段階で抑止したいところです。

ここからの実装は、少しややこしいのですが、別の設定用ワークフローを作って、それを使ってトリガーのフィルターを適切に設定する方法をご紹介します。

ここの実装の詳細は、多くの内容は以下の記事と重なりますので、

https://qiita.com/seratch/items/7a42e115f5510f6a1912

この記事ではコードコメントもほぼ削除した簡潔な説明にとどめます。この記事の説明ではわかりにくいと感じられた方は上の記事もあわせて参照いただければと思います。

コードの追加に入る前に、動作確認時の混乱を防ぐために `slack triggers create` で作成した "app_mentioned" イベントトリガーは削除しておきましょう。削除手順は `slack triggers list` で一覧を表示して、Trigger ID をコピーして `slack triggers delete --trigger-id {ここに Trigger ID}` を実行します。

それでは、設定用ワークフローを作っていきます。

"app_mentioned" トリガーの操作関連を `manage_triggers.ts` として保存します。

```typescript
import { SlackAPIClient } from "deno-slack-api/types.ts";

const triggerEventType = "slack#/events/app_mentioned";
const triggerName = "app_mentioned event trigger";
const triggerInputs = {
  channel_id: { value: "{{data.channel_id}}" },
  channel_type: { value: "{{data.channel_type}}" },
  channel_name: { value: "{{data.channel_name}}" },
  user_id: { value: "{{data.user_id}}" },
  text: { value: "{{data.text}}" },
};

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.ok) {
    throw new Error(JSON.stringify(listResponse));
  }
  if (listResponse && listResponse.triggers) {
    for (const trigger of listResponse.triggers) {
      if (
        trigger.workflow.callback_id === workflowCallbackId &&
        trigger.event_type === triggerEventType
      ) {
        return trigger;
      }
    }
  }
  return undefined;
}

export async function createOrUpdateTrigger(
  client: SlackAPIClient,
  workflowCallbackId: string,
  channelIds: string[],
  triggerId?: string,
): Promise<void> {
  // deno-lint-ignore no-explicit-any
  const channel_ids = channelIds as any;

  const authTest = await client.auth.test({});
  if (authTest.error) {
    const error = `Failed to call auth.test API: ${JSON.stringify(authTest)}`;
    throw new Error(error);
  }
  const botUserId = authTest.user_id;
  const filter = {
    version: 1,
    root: { statement: `{{data.text}} CONTAINS <@${botUserId}>` },
  };
  if (triggerId) {
    const update = await client.workflows.triggers.update({
      trigger_id: triggerId,
      type: "event",
      name: triggerName,
      workflow: `#/workflows/${workflowCallbackId}`,
      event: { event_type: triggerEventType, channel_ids, filter },
      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, filter },
      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)}`);
  }
}
```

続いて、これを使う自前のファンクションである `configure.ts` を以下の内容で保存します。

```typescript
import { DefineFunction, Schema, SlackFunction } from "deno-slack-sdk/mod.ts";
import {
  createOrUpdateTrigger,
  findTriggerToUpdate,
} from "./manage_triggers.ts";

export const def = DefineFunction({
  callback_id: "configure",
  title: "Configure a trigger",
  source_file: "configure.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 { 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 };
      }
      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:",
              },
            },
          ],
        },
      };
    },
  );
```

そして、このファンクションを実行する設定用のワークフローである `configurator.ts` を追加します。

```typescript
// -------------------------
// ワークフロー定義
// -------------------------
import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";

export const workflow = DefineWorkflow({
  callback_id: "configurator",
  title: "Trigger Configurator",
  input_parameters: {
    properties: {
      interactivity: { type: Schema.slack.types.interactivity },
    },
    required: ["interactivity"],
  },
});

import { def as Configure } from "./configure.ts";
import { workflow as ExampleWorkflow } from "./example.ts";

workflow.addStep(Configure, {
  interactivity: workflow.inputs.interactivity,
  // ここには example.ts の方の callback_id を指定する
  // 間違えてこの "設定用" ワークフローの方を設定しないよう注意
  workflowCallbackId: ExampleWorkflow.definition.callback_id,
});

// -------------------------
// トリガー定義
// -------------------------
import { Trigger } from "deno-slack-api/types.ts";
const trigger: Trigger<typeof workflow.definition> = {
  type: "shortcut",
  name: "Configurator Trigger",
  workflow: `#/workflows/${workflow.definition.callback_id}`,
  inputs: { interactivity: { value: "{{data.interactivity}}" } },
};
export default trigger;
```

そして、`configurator.ts` のワークフローを `manifest.ts` に追加します。また、`botScopes` にトリガー操作関連の権限も追加します。

```typescript
import { Manifest } from "deno-slack-sdk/mod.ts";
import { workflow as ExampleWorkflow } from "./example.ts";
import { workflow as ConfiguratorWorkflow } from "./configurator.ts";

export default Manifest({
  name: "wizardly-squirrel-338",
  description: "Demo app",
  icon: "assets/default_new_app_icon.png",
  workflows: [ExampleWorkflow, ConfiguratorWorkflow],
  outgoingDomains: [],
  botScopes: [
    "commands",
    "chat:write",
    "chat:write.public",
    "app_mentions:read", // trigger に必要
    "triggers:read", // configurator に必要
    "triggers:write", // configurator に必要
  ],
});
```

この状態で `slack run` でエラーが出ていないかを確認した上で　`slack triggers create --trigger-def ./configurator.ts` によって作成されたリンクトリガーを、チャンネルで共有して起動します。

以下のようにモーダルが開きますので "app_mentioned" イベントでワークフローを開始したいチャンネルを追加します。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/123fb48f-3f26-951f-afdd-c2212d65009b.png" width=400 />

設定が完了したら、ボットを招待 & メンションして動作確認してみてください。自アプリのボットのときだけワークフローが開始されるはずです。

### 終わりに

いかがだったでしょうか？

前回の "message_posted" のケースと同様、プラットフォーム側の不具合で大変お手数をおかけします。お恥ずかしい限りなのですが、現時点で可能な回避策をお示しすべく、その詳細を記事にいたしました。

利用可能なイベントトリガーの一覧やその詳細情報については以下のページをご参照ください。

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

それでは！