Slack 次世代プラットフォーム機能を少しずつ試す - 外部通信するファンクション編

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

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

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

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

前回の記事では、Slack API を呼び出すだけのカスタムファンクションを実装してみました。

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

今回の記事では、外部の API を呼び出すファンクションを追加してみましょう。なお、この記事で初めて次世代プラットフォーム機能を知ったという方は、はじめに[利用する上での前提条件](https://qiita.com/seratch/items/8c91bb367bb942dc0ffe#%E5%89%8D%E6%8F%90%E6%9D%A1%E4%BB%B6)をご確認ください。

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

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

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

今回は `./traslator` というディレクトリを作って、そこに三つのファイルを置いていくことにしましょう。

* `workflow.ts` - 翻訳を実行するワークフローを定義
* `trigger.ts` - 翻訳を開始するためのリンクトリガー
* `function.ts` - 翻訳を行うカスタムファンクション

なお、[こちらの記事でも触れましたが](https://qiita.com/seratch/items/8c91bb367bb942dc0ffe#%E3%81%BE%E3%81%A8%E3%82%81)、より複雑なワークフローの実装を行う場合 `functions`、`workflows`、`triggers` のようなディレクトリを作って、そこにファイルの種類ごとにまとめていく方法を推奨しています。しかし、この記事のようにワークフローごとに分類する方法でも動作するアプリを実装することは可能です。

### `translator/function.ts` を新しく作成する

今回は、[DeepL のテキスト翻訳 API](https://www.deepl.com/en/docs-api) を使ったファンクションの実装例をご紹介します。以下のソースコードを `translator/function.ts` として保存してください。

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

// ファンクションのメタデータを定義
export const def = DefineFunction({
  callback_id: "translator",
  title: "Translator",
  description: "A funtion to translate text",
  source_file: "translator/function.ts", // このファイルパスはファイルを移動したり名前を変えたら同期する必要があります
  input_parameters: {
    properties: {
      // 翻訳対象のテキスト
      text: { type: Schema.types.string },
      // DeepL API に渡す翻訳前の言語コード（オプショナル）
      source_lang: { type: Schema.types.string },
      // DeepL API に渡す翻訳後の言語コード
      target_lang: { type: Schema.types.string },
    },
    required: ["text", "target_lang"],
  },
  output_parameters: {
    properties: {
      translated_text: { type: Schema.types.string },
    },
    required: ["translated_text"],
  },
});

// ハンドラーの処理を設定して export default することで
// ワークフローに組み込めるようになる
export default SlackFunction(def, async ({
  inputs,
  env,
}) => {
  // slack env add DEEPL_AUTH_KEY [値] であらかじめ設定しておく必要がある
  const authKey = env.DEEPL_AUTH_KEY;
  if (!authKey) {
    // 異常終了として error のみを返してワークフローの実行を終了する
    return { error: "DEEPL_AUTH_KEY env value is missing!" };
  }
  const apiSubdomain = authKey.endsWith(":fx") ? "api-free" : "api";
  const apiUrl = `https://${apiSubdomain}.deepl.com/v2/translate`;
  // パラメーターとヘッダーを準備
  const body = new URLSearchParams();
  body.append("auth_key", authKey);
  body.append("text", inputs.text);
  if (inputs.source_lang) {
    body.append("source_lang", inputs.source_lang.toUpperCase());
  }
  body.append("target_lang", inputs.target_lang.toUpperCase());
  const requestHeaders = {
    "content-type": "application/x-www-form-urlencoded;charset=utf-8",
  };
  // DeepL API を fetch 関数で呼び出し
  const apiResponse = await fetch(apiUrl, {
    method: "POST",
    headers: requestHeaders,
    body,
  });
  const apiResponseStatus = apiResponse.status;
  if (apiResponseStatus != 200) {
    const body = await apiResponse.text();
    console.log(`apiResponse: ${apiResponse}, body: ${body}`);
    const error =
      `DeepL API call failed - status: ${apiResponseStatus}, body: ${body}`;
    // 異常終了として error のみを返してワークフローの実行を終了する
    return { error };
  }
  // レスポンスボディをパースして翻訳結果を取得
  const translationResult = await apiResponse.json();
  if (!translationResult || translationResult.translations.length === 0) {
    const error = `Translation failed for some reason: ${
      JSON.stringify(translationResult)
    }`;
    console.log(error);
    // 異常終了として error のみを返してワークフローの実行を終了する
    return { error };
  }
  // output_parameters に適合する outputs を返す
  return {
    outputs: {
      translated_text: translationResult.translations[0].text,
    },
  };
});
```

このファンクションはワークフローから、少なくとも `text`, `target_lang` の二つの inputs を受け取らないと起動できません。この二つを与えられるようなワークフローを考えてみましょう。

### `translator/workflow.ts` を新しく作成する

以下のコードを `translator/workflow.ts` として保存してください。このワークフローは二つの標準ファンクションと先ほど実装したカスタムファンクションの合計三つのファンクションで構成されています。

* `Schema.slack.functions.OpenForm` - シンプルなモーダルフォームを開いてユーザーの入力を受け取る
* `./function.ts` - DeepL API を呼び出してテキスト翻訳を行う
* `Schema.slack.functions.SendMessage` - トリガーが実行されたチャンネルに翻訳結果を投稿する

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

// この定義オブジェクトを manifest.ts で参照するのを忘れずに！
const workflow = DefineWorkflow({
  callback_id: "translator-workflow",
  title: "Translator Workflow",
  input_parameters: {
    properties: {
      // リンクトリガーから受け取るチャンネル ID
      channel_id: { type: Schema.slack.types.channel_id },
      // OpenForm で入力フォームを開くために必要
      interactivity: { type: Schema.slack.types.interactivity },
    },
    required: ["channel_id", "interactivity"],
  },
});

// 標準ファンクションのフォームを使って翻訳対象を取得
const formStep = workflow.addStep(Schema.slack.functions.OpenForm, {
  title: "Run DeepL Translator",
  interactivity: workflow.inputs.interactivity,
  submit_label: "Translate",
  fields: {
    elements: [
      {
        name: "text",
        title: "Text to translate",
        type: Schema.types.string,
      },
      {
        name: "target_lang",
        title: "Target Language",
        type: Schema.types.string,
        description: "Select the language to translate into",
        enum: [
          "English",
          "Japanese",
          "Korean",
          "Chinese",
          "Italian",
          "French",
          "Spanish",
        ],
        choices: [
          { value: "en", title: "English" },
          { value: "ja", title: "Japanese" },
          { value: "kr", title: "Korean" },
          { value: "zh", title: "Chinese" },
          { value: "it", title: "Italian" },
          { value: "fr", title: "French" },
          { value: "es", title: "Spanish" },
        ],
        default: "en",
      },
    ],
    required: ["text", "target_lang"],
  },
});

// 自前の ./function.ts を import してきて、それを使って翻訳
import { def as Translator } from "./function.ts";
const translatorStep = workflow.addStep(Translator, {
  text: formStep.outputs.fields.text,
  target_lang: formStep.outputs.fields.target_lang,
});

// 標準ファンクションで翻訳結果を投稿
workflow.addStep(Schema.slack.functions.SendMessage, {
  channel_id: workflow.inputs.channel_id,
  message:
    `>${formStep.outputs.fields.text}\n${translatorStep.outputs.translated_text}`,
});

// default でもそうじゃなくてもどっちでもいい
export default workflow;
```

### `mafinest.ts` を編集する

`manifest.ts` には、二つの変更を加える必要があります。

* `workflows` に上で定義したワークフローを追加する
* `outgoingDomains` に DeepL の API 呼び出しでアクセスするドメインを追加する

特に二つ目の点はこれまでの例では出てこなかったポイントですので、忘れずにすべてのドメインを列挙するようにしてください。

```typescript
import { Manifest } from "deno-slack-sdk/mod.ts";
import TranslatorWorkflow from "./translator/workflow.ts";

export default Manifest({
  name: "zealous-elk-261",
  description: "Translate text in Slack",
  icon: "assets/default_new_app_icon.png",
  workflows: [TranslatorWorkflow],
  // slack.com 以外の全てのドメインが列挙されている必要があります
  outgoingDomains: ["api-free.deepl.com", "api.deepl.com"],
  botScopes: ["commands", "chat:write", "chat:write.public"],
});
```

ここまででワークフローの準備が整いました。`slack run` で何かエラーが出ていないかを確認してください。

### `translator/trigger.ts` を新しく作成する

このワークフローを実行するために、[リンクトリガー](https://api.slack.com/future/triggers/link)をつくりましょう。以下のコードを `translator/trigger.ts` として保存します。

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

const trigger: Trigger<typeof workflow.definition> = {
  // リンクトリガー
  type: "shortcut",
  name: "Translator Trigger",
  workflow: `#/workflows/${workflow.definition.callback_id}`,
  inputs: {
    // クリックしたチャンネルの ID が設定される
    channel_id: { value: "{{data.channel_id}}" },
    // フォームを開くために必要なのでワークフローに引き渡す
    interactivity: { value: "{{data.interactivity}}" },
  },
};

// トリガーの作成には `slack triggers create --trigger-def [ファイルパス]` を実行する
// Trigger 形の定義オブジェクトを export default さえしていれば
// そのソースファイルを使用できる
export default trigger;
```

前回の記事との違いは inputs に interactivity が追加されている点です。これはワークフローの中の OpenForm を使うために必要なパラメーターとなりますので、忘れずに追加してください。

### `slack triggers create` コマンドでリンクトリガーを作成する

上記のコードを使ってトリガーを作成します。`slack triggers create --trigger-def translator/trigger.ts` を実行してください。

```bash
$ slack triggers create --trigger-def translator/trigger.ts
? Choose an app  seratch (dev)  T03E94MJU
   zealous-elk-261 (dev) A04DFPX7NEB

⚡ Trigger created
   Trigger ID:   Ft04DYQXXXXX
   Trigger Type: shortcut
   Trigger Name: Translator Trigger
   URL: https://slack.com/shortcuts/Ft04DYQXXXXX/YYYYYYYYYYYYYYYYY
```

### `.env` ファイルを配置して env に DeepL API トークンを設定する

最後に `slack env` を設定したら、手順は終了です。`slack deploy` でデプロイしたアプリに対しては `slack env add [KEY] [VALUE]` というコマンドで env の値を設定することができます。これはファンクションのコードで `env` として引数で受け取っているものです。

開発版アプリだけの状態で `slack env add` コマンドを実行すると、以下のようなエラーとなります。

```bash
$ slack env add DEEPL_AUTH_KEY XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Check /Users/kazuhiro.sera/.slack/logs/slack-debug-20221206.log for full error logs

🚫  Error: A valid installation of this app is required to take this action (installation_required)

Suggestion:

Run the `slack deploy` command
```

開発版アプリのローカル実行では、代わりにプロジェクトのルートディレクトリに `.env` ファイルを配置するとこれを読み込んでくれるようになっています。ファイルに以下のキーの値を書き込んで保存してください。

```
DEEPL_AUTH_KEY=[YOUR API KEY HERE]
```

そして、開発が終わり、ワークスペース内で他の人と使い始める段階になったら `slack deploy` をするとともに `slack env add` で本番用の API キーを設定するようにしてください。

これで設定が完了しました。以下がこのアプリを稼働させるのに最低限必要なファイル群となります。

```
$ tree -a
.
├── .env
├── .gitignore
├── .slack
│   ├── apps.dev.json
│   └── apps.json
├── .vscode
│   └── settings.json
├── assets
│   └── default_new_app_icon.png
├── deno.jsonc
├── import_map.json
├── manifest.ts
├── slack.json
└── translator
    ├── function.ts
    ├── trigger.ts
    └── workflow.ts

4 directories, 13 files
```

### Slack 内でワークフローを起動する

いよいよ実行していましょう。チャンネルにリンクトリガーを配置したらそれをクリックします。以下のようなモーダルダイアログが立ち上がるはずです。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/5960d76b-447f-8f89-e854-e13112a9afce.png" width=500 />

送信すると、翻訳結果が以下のようにチャンネルに投稿されます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19163/00827773-c9fd-e4dd-5d99-4dbad0640bfa.png" width=500 />

実際に使えるものにしていくことを考えると「チャンネルメッセージではなく、モーダル内に翻訳結果を表示したい」「チャンネルに投稿するのはよいが、改行を含むテキストを受け取れるようにしたい」など、色々と改善のアイデアが出てきます。

これらを実装するにあたっては、標準ファンクションだと限界がありますので、OpenForm の代わりにフル機能のモーダルを開くカスタムのファンクション、SendMessage の代わりに前回の記事でやったように chat.postMessage API を自前で実行するカスタムファンクションなどを実装するとよいでしょう。

より高度なモーダルの扱いについては、近いうちに記事で解説する予定です。

### 終わりに

この記事では、外部 API の呼び出しを行うカスタムファンクションの実装例をご紹介しました。注意点としては、以下の点を押さえておくとよいでしょう。

* 標準の fetch 関数を使って HTTP リクエストを送信する
* `manifest.ts` の `outgoingDomains` を設定する
* API キーなどはローカル開発では `.env` ファイルに記述、本番では `slack env add` で設定する

次の記事では、ユニットテストの例をご紹介する予定です。

それでは！