🐷

新しいSlackのPlatformで翻訳botを作ってみる

2022/10/05に公開

前回の記事に引き続きSlackのPlatformで遊ぶ。今回はもう少し実践的なbotを作ってみた。

作るもの

@honyaku ja_en これはペンですみたいな感じでbotにmentionをするとThis is a pen.のように英語翻訳して結果を返してくれるbotを作りたい。

Datastoreも使ってみたいので翻訳する機能と翻訳用に使う予定のDeepLのAPIキーを設定するコマンドも作る。

イメージはこんな感じ↓。

// APIキーの設定
@honyaku set_api_key XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
> Result: Success

// 翻訳(JA => EN)
@honyaku ja_en これはりんごです。
> Result: This is an apple.

// 翻訳(EN => JA)
@honyaku en_ja This is a pen.
> Result: これはペンです。

実践

プロジェクトを作成

$ slack create honyaku

今回はScaffolded projectを選択した。

開発の流れ

botを作る際は、botへのメンション内容がInputになり、そのInputを色々処理して翻訳結果をメッセージとして投稿する流れになる。新しいSlack PlatformではどんなFunction(処理)が必要何がInputで何をOutputにするか、そして何をトリガーにして何を発火させるか(エントリーポイント) を事前に設計しておくと開発が楽になる。

今回のbot作成で必要な処理の塊とInput&Outputは下記。

  • メンションの内容を受け付ける
    • app_mentionedイベントをフックにWorkflowを発火させる。
    • Input
      • text: メンションの内容
      • userId: 誰からのメンションか
      • channelId: どのチャンネルで実行されたか
  • メンションの内容をパースする
    • 上記のメンションの内容を受け取り
    • Input
      • body: メンションの内容
        • @honyaku ja_en これはペンです
    • Output:
      • command: メンションの内容をパースした結果のコマンド部分
        • ja_en
      • body: メンションの内容をパースした結果の中身の部分
        • これはペンです
  • パースした結果を元にコマンドごとに処理を実行する
    • Input
      • command: コマンド
        • ja_en
      • body: 中身
        • これはペンです
      • channelId: botが実行されたチャンネルのID
        • XXXXXXXXXX
    • Output
      • result: 実行結果のテキスト
        • This is a pen.
  • メッセージを送信する
    • Input
      • channelId: botが実行されたチャンネルのID
        • XXXXXXXXXX
      • text: 生成結果
        • This is a pen.

こんな感じの内容をWorkflow/Function/Triggerとして記述していく。

Workflowを定義する

WorkflowではFunctionに何のInputを与えるか、実行結果のOutputを他のFunctionへどう受け渡すかを定義する。ソースコードは以下。Workflowにどの関数にどのInputを渡すのか?をaddStepで登録していくだけ。

import {
  DefineWorkflow,
  Schema,
} from "https://deno.land/x/deno_slack_sdk@1.1.2/mod.ts";
import { ExtractFunction } from "../functions/extract.ts";
import { ExecuteFunction } from "../functions/execute.ts";
import { SendFunction } from "../functions/send.ts";

export const HonyakuWorkflow = DefineWorkflow({
  callback_id: "honyaku",
  title: "Honyaku Workflow",
  input_parameters: {
    properties: {
      text: {
        type: Schema.types.string,
      },
      userId: {
        type: Schema.slack.types.user_id,
      },
      channelId: {
        type: Schema.slack.types.channel_id,
      },
    },
    required: ["text", "userId", "channelId"],
  },
});

const extractStep = HonyakuWorkflow.addStep(ExtractFunction, {
  body: HonyakuWorkflow.inputs.text,
});

const executeStep = HonyakuWorkflow.addStep(ExecuteFunction, {
  command: extractStep.outputs.command,
  body: extractStep.outputs.body,
  channelId: HonyakuWorkflow.inputs.channelId,
});

HonyakuWorkflow.addStep(SendFunction, {
  channelId: HonyakuWorkflow.inputs.channelId,
  text: executeStep.outputs.result,
});

export default HonyakuWorkflow;

Functionを定義する

Functionとしては下記を作成する。

テキストをパースしてコマンドを作成するFunction

正規表現でcommandとbody部分に分割してoutputsとしてreturnするだけ。

import {
  DefineFunction,
  Schema,
  SlackFunction,
} from "https://deno.land/x/deno_slack_sdk@1.1.2/mod.ts";

export const ExtractFunction = DefineFunction({
  callback_id: "extract",
  title: "Extract",
  description: "Extracts text from a message",
  source_file: "functions/extract.ts",
  input_parameters: {
    properties: {
      body: {
        type: Schema.types.string,
      },
    },
    required: ["body"],
  },
  output_parameters: {
    properties: {
      command: {
        type: Schema.types.string,
      },
      body: {
        type: Schema.types.string,
      },
    },
    required: [],
  },
});

// Example:
// @honyaku ja_en これはペンです。
// @honyaku set_api_key XXXXXXXXXXX
export default SlackFunction(ExtractFunction, ({ inputs }) => {
  const regExp = /\<\@.+?\>\s?(set_api_key|[a-z]{2,4}_[a-z]{2,4})\s?(.+)$/;
  const { body } = inputs;
  const m = body.match(regExp);
  if (m) {
    const command = m[1];
    const body = m[2];
    return {
      outputs: {
        command,
        body,
      },
    };
  }
  return {
    outputs: {},
  };
});

コマンドを実行するFunction

ここがbotの要の処理を記述する場所。

まずキーを保存するためのDatastoreを定義する。channel_idをprimary idとしてapi keyを保存するようにした。

import {
  DefineDatastore,
  Schema,
} from "https://deno.land/x/deno_slack_sdk@1.1.2/mod.ts";

export const DATASTORE_NAME = "honyaku";

export const Datastore = DefineDatastore({
  name: DATASTORE_NAME,
  primary_key: "id",
  attributes: {
    id: {
      type: Schema.slack.types.channel_id,
    },
    apiKey: {
      type: Schema.types.string,
    },
  },
});

次にapikeyのsetを行うコマンド。ここではdatastoreにキーを格納する。同じidでputすると上書きになるっぽい。

で、あとはja_enみたいな翻訳コマンド。これはテキストを翻訳するやつ。datastoreからapi keyを取得してdeeplのAPIへ投げる。結果を整理して次のFunctionへ渡す。

少々長いがコードとしては難しいことはしてない。

import {
  DefineFunction,
  Schema,
  SlackFunction,
} from "https://deno.land/x/deno_slack_sdk@1.1.2/mod.ts";
import { SlackAPI } from "deno-slack-api/mod.ts";
import { Datastore, DATASTORE_NAME } from "../datastores/datastore.ts";

export const ExecuteFunction = DefineFunction({
  callback_id: "execute",
  title: "Execute",
  source_file: "functions/execute.ts",
  input_parameters: {
    properties: {
      command: {
        type: Schema.types.string,
      },
      body: {
        type: Schema.types.string,
      },
      channelId: {
        type: Schema.slack.types.channel_id,
      },
    },
    required: ["command", "body", "channelId"],
  },
  output_parameters: {
    properties: {
      result: {
        type: Schema.types.string,
      },
    },
    required: [],
  },
});

export default SlackFunction(ExecuteFunction, async ({ inputs, token }) => {
  if (!inputs.command || !inputs.body || !inputs.channelId) {
    return { outputs: {} };
  }

  const client = SlackAPI(token, {});
  const command = inputs.command;
  const body = inputs.body;
  let result = "Command not found";

  if (command === "set_api_key") {
    const response = await client.apps.datastore.put({
      datastore: DATASTORE_NAME,
      item: {
        id: inputs.channelId,
        apiKey: body,
      },
    });
    result = "Success";
    if (!response.ok) {
      result = "Failed to set API key";
    }
  } else if (command.match(/[a-z]{2,4}_[a-z]{2,4}/)) {
    const queryResult = await client.apps.datastore.query<
      typeof Datastore.definition
    >({
      datastore: DATASTORE_NAME,
      expression: `#id = :channelId`,
      expression_attributes: {
        "#id": "id",
      },
      expression_values: { ":channelId": inputs.channelId },
    });

    if (queryResult.ok) {
      if (queryResult.items && queryResult.items.length > 0) {
        const apiKey = queryResult.items[0].apiKey;
        const langs = command.split("_");
        result = await translateText(apiKey, body, langs[0], langs[1]);
      } else {
        result = "Set API Key first.";
      }
    } else {
      result = "Failed to get API key";
    }
  }

  return { outputs: { result } };
});

const translateText = async (
  apiKey: string,
  text: string,
  sourceLang: string,
  targetLang: string,
) => {
  const url = `https://api-free.deepl.com/v2/translate`;
  const params = new URLSearchParams();
  params.append("auth_key", apiKey);
  params.append("text", text);
  params.append("source_lang", sourceLang.toUpperCase());
  params.append("target_lang", targetLang.toUpperCase());
  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: params,
  });
  const json = await response.json();
  if (json.translations && json.translations.length > 0) {
    return json.translations[0].text;
  }
  return `Translation Error - ${sourceLang} -> ${targetLang}`;
};

生成した結果を送信するFunction

コマンド実行の結果を受け取りSlackの該当チャンネルへpostするだけ。

import {
  DefineFunction,
  Schema,
  SlackFunction,
} from "https://deno.land/x/deno_slack_sdk@1.1.2/mod.ts";
import { SlackAPI } from "deno-slack-api/mod.ts";

export const SendFunction = DefineFunction({
  callback_id: "send",
  title: "Send",
  description: "Send a message",
  source_file: "functions/send.ts",
  input_parameters: {
    properties: {
      channelId: {
        type: Schema.slack.types.channel_id,
      },
      text: {
        type: Schema.types.string,
      },
    },
    required: ["channelId", "text"],
  },
});

export default SlackFunction(SendFunction, ({ inputs, token }) => {
  const { channelId, text } = inputs;
  const client = SlackAPI(token, {});
  client.chat.postMessage({
    channel: channelId,
    text: `Result: ${text}`,
  });
  return {
    outputs: {},
  };
});

Triggerを定義する

今回は@honyakuという形でmentionを受け取って発火してほしいのでapp_mentionedイベントを使う。このイベントが発行された時に先ほど定義したWorkflowが実行されるようになる。

import { Trigger } from "deno-slack-api/types.ts";
import { HonyakuWorkflow } from "../workflows/honyaku_workflow.ts";
import { CHANNEL_IDS } from "../env.ts";

const trigger: Trigger<typeof HonyakuWorkflow.definition> = {
  type: "event",
  event: {
    event_type: "slack#/events/app_mentioned",
    channel_ids: CHANNEL_IDS,
  },
  name: "Mention trigger",
  workflow: "#/workflows/honyaku",
  inputs: {
    "text": {
      value: "{{data.text}}",
    },
    "userId": {
      value: "{{data.user_id}}",
    },
    "channelId": {
      value: "{{data.channel_id}}",
    },
  },
};

export default trigger;

manifest.tsの定義

datastoreを使う旨やどのイベントを使うかなどの権限を書く。Chrome Extensionを作ったことがある人はあのmanifest.jsonと同じイメージでOK。

import { Manifest } from "deno-slack-sdk/mod.ts";
import { Datastore } from "./datastores/datastore.ts";
import HonyakuWorkflow from "./workflows/honyaku_workflow.ts";

export default Manifest({
  name: "honyaku",
  description: "Honyaku Bot",
  icon: "assets/icon.png",
  workflows: [HonyakuWorkflow],
  outgoingDomains: [],
  datastores: [Datastore],
  botScopes: [
    "app_mentions:read",
    "chat:write",
    "chat:write.public",
    "datastore:read",
    "datastore:write",
  ],
});

挙動テスト

作成したbotをdevに登録してテストする。

$ slack run
$ slack trigger create --trigger-def "triggers/mention.ts"

出来た!

最後に

前回はHello Worldレベルのアプリだったが今回はより実践的なBotを開発してみた。

改めて感じたのは何もしなくてもデフォルトのパッケージだけでそれなりのものが作れるし、型推論が全てに綺麗についていて書いていて心地よいということ。さらにslack runしておけばHot Reloadでコードがbotへ反映されるからデバッグもしやすい。DynamoDBシンタックスなのはやや抵抗感があるが、Datastore自体はなかなか優れもので、これを自前で用意せずに動かせるのは便利。前回も書いたけどやはり全体的にDXがかなり良いという印象。

そういえばDenoは初めて使ったのだがTypeScriptに慣れていれば特に身構えることはなかったな。

ちなみに今回作ったbotのコードはここに置いてあるので上記の解説で何かわからないことがあれば適宜参照してください。
https://github.com/YuheiNakasaka/slack-deno-honyaku

Discussion