新しいSlackのPlatformで翻訳botを作ってみる
前回の記事に引き続き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 これはペンです
- body: メンションの内容
- Output:
- command: メンションの内容をパースした結果のコマンド部分
ja_en
- body: メンションの内容をパースした結果の中身の部分
これはペンです
- command: メンションの内容をパースした結果のコマンド部分
- パースした結果を元にコマンドごとに処理を実行する
- Input
- command: コマンド
ja_en
- body: 中身
これはペンです
- channelId: botが実行されたチャンネルのID
XXXXXXXXXX
- command: コマンド
- Output
- result: 実行結果のテキスト
This is a pen.
- result: 実行結果のテキスト
- Input
- メッセージを送信する
- Input
- channelId: botが実行されたチャンネルのID
XXXXXXXXXX
- text: 生成結果
This is a pen.
- channelId: botが実行されたチャンネルのID
- Input
こんな感じの内容を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のコードはここに置いてあるので上記の解説で何かわからないことがあれば適宜参照してください。
Discussion