Slack上で動く翻訳ボットを作った話

こんにちは、エンジニアの @akase244 です。

www.facebook.com

www.facebook.com

www.facebook.com

当社のFacebookページの投稿を見ていただくと、こんな感じでイノベーター・ジャパンではなにかと外国の方との接する機会が多いことが分かるかと思います。
去年、「日本語がほとんど話せないメンバーが入社した」ことで、社内で英語によるコミュニケーションを迫られるシーンがさらに増えました。

しかしながら、英語で問題なくコミュニケーションできるのはほんの数名です。。。

オンラインミーティングでは、Google翻訳の音声入力を試してみたり、英語が話せるメンバーにサポートしてもらったりと色々工夫してますが、なかなかうまくいかない場合もあり、最終的には翻訳サービスで翻訳した内容をSlackに貼り付けて黙って筆談という状態になってたりもします。

だったら、Slack上でもっと手軽に翻訳できるようにしたらいいじゃん。ということで今回翻訳ボットを作ってみました。

開発前夜

翻訳ボットを開発するちょっと前に「Translate for Slack」という /translate で翻訳できるSlackのスラッシュコマンドを導入していました。

f:id:akase244:20180227160108p:plain

しかし、スラッシュコマンドで動くということは、翻訳前の文章が実行後に消えます。

スラッシュコマンド実行前

f:id:akase244:20180227160613p:plain

そして、この機能の残念なところは翻訳結果が「Only visible to you」状態で表示される点です。(翻訳後に表示される「Post This」ボタンをクリックすることで、投稿することも可能なようですが正直手間)

スラッシュコマンド実行後

f:id:akase244:20180227183708p:plain

翻訳結果が本人にしか見えないのでは、コミュニケーションのツールとしては使えません。
これを踏まえ、ボットを作る際にはスラッシュコマンドではなくSlackのOutgoing Webhooksで投稿に反応するものにしようと考えました。

作る意気込み的なもの

翻訳ボットは巷に溢れまくってますし何番煎じだよという感じですが、作る意味みたいなものを自分なりに考えて着手しました。

  • 最近、技術調査をやってなかったので、時間を取ってやりたかった。
  • サーバーレスアーキテクチャ、FaaSと呼ばれるものについて触れとかないとな、と以前から思っており、ボット開発は規模的にちょうど良さそうだった。
  • AWS Lambdaの実装例は社内にあったが、Azure Functionsは無かったので一例目にしたかった。

仕様をざっくりと説明

翻訳している様子

日本語から英語
f:id:akase244:20180227165725p:plain

英語から日本語
f:id:akase244:20180227165738p:plain

韓国語から日本語
f:id:akase244:20180227165748p:plain

なぜか擬人化

デザイナーの9ookyがこの翻訳機能をいたく気に入ってくれたようで、ある日突然擬人化されました。
本人曰く、「transちゃんの「T」型を模した髪型がポイント」だそうです。

最初のバージョンはこれ。
f:id:akase244:20180227172406j:plain

その後、「髪の毛青いのはイメージと違う!」との意見があったようでモデルチェンジ(しかも解説付き)しました。
f:id:akase244:20180227172523j:plain

「Slackボットのアイコンに使いたい」とお願いをすると、正方形に収まるようにヘアアレンジが入りました。
f:id:akase244:20180227172747j:plain:w200

そして、「ブログ用に大きめの画像がほしいんですけど。。。」とお願いしてみると、最終的に渡されたのはこの画像でした。ちょっと気合い入れすぎでしょ。。。
f:id:akase244:20180227173832j:plain

実際のコード

module.exports = function (context, req) {
    const qs = require('querystring');
    const request = require('request');
    const xml2js = require('xml2js');

    const body = qs.parse(req.body);
    if (body.token != process.env['SLACK_WEBHOOKS_TOKEN']) {
        context.res
            .status(500);
    }
    const text = body.text;
    const user_name = body.user_name;
    const matches = text.match(/^trans[\s ](([\n\r]|.)*)/);

    if (matches && user_name !== 'slackbot') {
        const translate_target = matches[1];

        const subscription_key = process.env['TRANSLATOR_TEXT_API_KEY'];
        const token_headers = {
            'Accept': 'application/jwt',
            'Ocp-Apim-Subscription-Key': subscription_key
        };
        const token_url = 'https://api.cognitive.microsoft.com/sts/v1.0/issueToken';
        const token_options = {
            url: token_url,
            method: 'POST',
            headers: token_headers,
        };
        request(token_options, function (error, response, body) {
            const access_token = body;

            const detect_url = 'http://api.microsofttranslator.com/v2/Http.svc/Detect?text=' + encodeURIComponent(translate_target);
            const detect_headers = {
                'Authorization': 'Bearer ' + access_token
            };
            const detect_options = {
                url: detect_url,
                method: 'GET',
                'headers': detect_headers
            };
            request(detect_options, function (error, response, body) {
                language_xml = body;
                xml2js.parseString(language_xml, function (err, res){
                    detect_language = res.string._;
                    const from = detect_language;
                    let to = 'ja';
                    let translated = '翻訳しました';
                    if (from === 'ja') {
                        to = 'en';
                        translated = 'Translated';
                    }
                    const translate_url = 'http://api.microsofttranslator.com/v2/Http.svc/Translate'
                        + '?from=' + from
                        + '&to=' + to
                        + '&category=generalnn'
                        + '&text=' + encodeURIComponent(translate_target);
                    const translate_headers = {
                        'Authorization': 'Bearer ' + access_token
                    };
                    const translate_options = {
                        url: translate_url,
                        method: 'GET',
                        'headers': translate_headers
                    };
                    request(translate_options, function (error, response, body) {
                        translate_xml = body;
                        xml2js.parseString(translate_xml, function (err, res){
                            translated_text = res.string._;

                            const payload = {
                                'username' : 'transちゃん',
                                'text' : translated + ' : ' + translated_text,
                                'icon_emoji' : ':trans:'
                            };
                            context.res
                                .status(200)
                                .json(payload);
                        });
                    });
                });
            });
        });
    }
};

作ってみた感想、課題、今後対応したいこと

  • ざっくりではあるが、Azure Functions(Function App)の使い方が把握できた。
  • Translator Text APIのレスポンスがXML形式でビビった。
  • コールバック地獄的な書き方になってしまってる。。。
  • かなりの頻度で使われているようで、作った甲斐があった。
  • Azure Functions の JavaScript 開発者向けガイド」を参照すると、「Node のバージョンは、現在、 6.5.0にロックされています。 」との記載があるが、「アプリケーション設定」の「WEBSITE_NODE_DEFAULT_VERSION」を修正することでバージョンが変更できるようなので、バージョンを7.6以上に上げたい。
  • Node.jsの7.6からasync/awaitが正式に対応されているので、「co」や「async-await」といったパッケージを追加せずとも、コールバック地獄的なコードから脱却できるはず。。。
  • Azure Functionsは数分間利用がない場合アイドル状態となり、次の実行時には時間が掛かってしまいます。(コールドスタートというらしいです)なので、同一リソース内で「"200 OK"だけを返す」といった単純な関数を作成して、アイドル状態にならないくらいの間隔で定期実行するといった対策を入れたい。
  • たまに翻訳に失敗してレスポンスを返さない場合がある(Translator Text APIでタイムアウトになってるのでは?)ので、そういったエラー処理も対応したい。