Slack 次世代プラットフォーム機能を少しずつ試す - 高度なモーダルインタラクション編

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

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

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

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

別の記事で、[標準のフォーム](https://api.slack.com/future/forms)を使ったワークフローの実装例をご紹介しました。

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

この標準のフォームは多くの用途に使えますが、以下のようなことはできません。

* [Block Kit](https://api.slack.com/block-kit) で提供されているすべての機能を自由に利用する
* フォーム送信時にカスタムの入力チェックを行う
* 複数の画面遷移を伴うより複雑なユーザーとのやりとりを行う
* ユーザーがモーダルでフォーム送信せず、閉じるボタンで離脱した場合に対応する

これらの用途には直接 [`views.open` API](https://api.slack.com/methods/views.open) を利用して、そのモーダルの送信を受け付ける実装を行う必要があります。この記事ではその方法をご紹介します。

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

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

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

### モーダルを操作するファンクションを追加

ワークフローに import されるファンクションを先に作成します。`function.ts` として以下の内容を保存してください。

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

export const def = DefineFunction({
  callback_id: "modal-example",
  title: "Modal interaction example",
  source_file: "function.ts",
  input_parameters: {
    properties: { interactivity: { type: Schema.slack.types.interactivity } },
    required: ["interactivity"],
  },
  output_parameters: { properties: {}, required: [] },
});

export default SlackFunction(
  def,
  // ---------------------------
  // ファンクションが実行されたときに最初に実行される処理
  // モーダルを開いて、後続のインタラクションにつなげる
  // ---------------------------
  async ({ inputs, client }) => {
    // 新しくモーダルを開く
    const response = await client.views.open({
      interactivity_pointer: inputs.interactivity.interactivity_pointer,
      view: {
        "type": "modal",
        "callback_id": "first-page",
        "notify_on_close": true, // view_closed を受け取るために必要
        "title": { "type": "plain_text", "text": "My App" },
        "submit": { "type": "plain_text", "text": "Next" },
        "close": { "type": "plain_text", "text": "Close" },
        "blocks": [
          {
            "type": "input",
            "block_id": "first_text",
            "element": { "type": "plain_text_input", "action_id": "action" },
            "label": { "type": "plain_text", "text": "First" },
          },
        ],
      },
    });
    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,
    };
  },
)
  // ---------------------------
  // 最初のビューで送信ボタンを押したときの処理
  // 二ページ目のビューを構築し private_metadata を引き渡す
  // ---------------------------
  .addViewSubmissionHandler(["first-page"], ({ view }) => {
    const firstText = view.state.values.first_text.action.value;
    if (firstText.length < 5) {
      return {
        response_action: "errors",
        errors: { first_text: "Must be 5 characters or longer" },
      };
    }
    return {
      response_action: "update",
      view: {
        "type": "modal",
        "callback_id": "second-page",
        "notify_on_close": true, // view_closed を受け取るために必要
        "title": { "type": "plain_text", "text": "My App" },
        "submit": { "type": "plain_text", "text": "Next" },
        "close": { "type": "plain_text", "text": "Close" },
        // Slack UI 上は見えない HTML のフォームでいうところの hidden のような値
        // 3,000 文字までの文字列として保持できる
        "private_metadata": JSON.stringify({ firstText }),
        "blocks": [
          // "first-page" から受け取った情報を表示
          {
            "type": "section",
            "text": { "type": "mrkdwn", "text": `First: ${firstText}` },
          },
          // 新しく入力を受けるブロック
          {
            "type": "input",
            "block_id": "second_text",
            "element": { "type": "plain_text_input", "action_id": "action" },
            "label": { "type": "plain_text", "text": "Second" },
          },
        ],
      },
    };
  })
  // ---------------------------
  // 二ページ目のビューで送信ボタンを押したときの処理
  // 二ページ分の入力を受け取った完了ページを表示する
  // ---------------------------
  .addViewSubmissionHandler(["second-page"], ({ view }) => {
    const { firstText } = JSON.parse(view.private_metadata!);
    const secondText = view.state.values.second_text.action.value;
    // 完了ページを表示
    return {
      response_action: "update",
      view: {
        "type": "modal",
        "callback_id": "completion",
        "notify_on_close": true, // view_closed を受け取るために必要
        "title": { "type": "plain_text", "text": "My App" },
        // ここからは入力を受け付けないので、あえて submit を表示しないよう外している
        "close": { "type": "plain_text", "text": "Close" },
        // 受け取った二つのテキストを section ブロックで表示するだけ
        "blocks": [
          {
            "type": "section",
            "text": { "type": "mrkdwn", "text": `First: ${firstText}` },
          },
          {
            "type": "section",
            "text": { "type": "mrkdwn", "text": `Second: ${secondText}` },
          },
        ],
      },
    };
  })
  // ---------------------------
  // モーダルが閉じられたときに呼ばれる処理
  // バックエンドサービスと連携している場合など、処理をキャンセルするなどの用途に使える
  // ---------------------------
  .addViewClosedHandler(
    ["first-page", "second-page", "completion"],
    ({ view }) => {
      console.log(`view_closed handler called: ${JSON.stringify(view)}`);
      return { completed: true };
    },
  );
```

### ワークフローを追加

上で保存したファンクションを import するだけのシンプルなワークフローを `workflow.ts` というファイル名で作成します。

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

import { DefineWorkflow, Schema } from "deno-slack-sdk/mod.ts";
export const workflow = DefineWorkflow({
  callback_id: "modal-demo-workflow",
  title: "Modal Demo Workflow",
  input_parameters: {
    properties: {
      interactivity: { type: Schema.slack.types.interactivity },
    },
    required: ["interactivity"],
  },
});

// ここにモーダルを使った自前のファンクションのステップを追加
import { def as ModalDemo } from "./function.ts";
workflow.addStep(ModalDemo, {
  interactivity: workflow.inputs.interactivity,
});

// ----------------
// トリガー定義
// ----------------

import { Trigger } from "deno-slack-api/types.ts";
const trigger: Trigger<typeof workflow.definition> = {
  type: "shortcut",
  name: "Modal Demo 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 DemoWorkflow } from "./workflow.ts";

export default Manifest({
  name: "sharp-chipmunk-480",
  description: "Modal interaction demo",
  icon: "assets/default_new_app_icon.png",
  workflows: [DemoWorkflow],
  outgoingDomains: [],
  botScopes: ["commands"],
});
```

`slack run` でアプリを起動してみて、エラーが発生していないことを確認してください。

### リンクトリガーを作成してワークフローを試す

`slack triggers create --trigger-def ./workflow.ts` を実行して、リンクトリガーを作成して、それをチャンネル内のメッセージかブックマークとして共有してください。


実行すると、以下のように入力バリデーションを返したり、複数ページの画面遷移を伴う、リッチなモーダルのインタラクションの挙動を確認することができます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/63dcc0dd-0912-cc42-5a72-a94c48b89eb7.gif" width=600 />

入力内容のバリデーションは、以下の箇所のコードで実装されています。桁数以外の実装も自由に実装することができます。


```typescript
  // ---------------------------
  // 最初のビューで送信ボタンを押したときの処理
  // 二ページ目のビューを構築し private_metadata を引き渡す
  // ---------------------------
  .addViewSubmissionHandler(["first-page"], ({ view }) => {
    const firstText = view.state.values.first_text.action.value;
    if (firstText.length < 5) {
      return {
        response_action: "errors",
        errors: { first_text: "Must be 5 characters or longer" },
      };
    }
```

注意点としては、**この `addViewSubmissionHandler` で渡した関数の中の処理は 3 秒以内に終了する必要があります**（この記事投稿時点では 3 秒以上かかっても一応動作するのですが、正式リリースまでに 3 秒以内での処理が求められるようになる可能性が高いです）。ですので、完了までに数秒かかるようなバックエンドの処理をこのエラー応答に組み込むことはできません。どうしてもそのような時間のかかるチェック処理を挟み込みたい場合は、一旦送信を受け付けるようにして、

* モーダルを「処理中」のような表示に更新して、そのまま待ってもらう。終わったら [views.update API](https://api.slack.com/methods/views.update) で表示を切り替える。
* モーダルを一旦閉じてしまった場合や非常に時間がかかるような場合は、完了時にそのユーザーに対して DM で通知をする。通知のメッセージにボタンを含めるようにして、そのボタンからモーダルを開いて続きのインタラクションを行う。

といった風に実装することをおすすめします。

なお、上のコード例で `response_action: "update"` を使ってモーダルの表示を切り替えていますが、この辺の詳しい解説は以下の記事で書きましたので、合わせてご参照ください。

https://qiita.com/seratch/items/0b1790697281d4cf6ab3

一点、こちらの記事を参照いただく上で注意点がありまして・・

次世代プラットフォームではないアプリのモーダルでは `trigger_id` という名称の必須項目のパラメーターのところが、次世代プラットフォームでは `interactivity_pointer` という名称となっております（役割としては同様のものなのですが）。コード例を流用するときは、そこだけ読み替えていただくようお願いします。:bow:

### 終わりに

いかがだったでしょうか？今回のようにモーダルをハンドリングするコードを書くことで、かなり柔軟な実装ができますので、非常に優れたユーザー体験を実現することができるはずです。

より詳細な情報は、以下のページをご参照ください。

https://api.slack.com/future/view-events

それでは！