🌏

Slackのメッセージを翻訳して返してくれるBotをGASのLanguageAppで作る

2021/10/14に公開

Slackで気軽に翻訳したい

弊社ではオフショア先とSlackでやりとりをしています。
オフショア先はブリッジSEが優秀な方々なので、日本語で連絡しても問題なく返してくれますが、細かいニュアンスが伝わらなかったり、実際に作業してくれるエンジニアさんは英語の方が得意だったりします。
そのため、Slack上で英語を挟んでやりとりすることもあります。

また、主にGCPを利用することが多いので、GCPのサービス稼働状況を通知させているのですが、サービス停止や遅延の発生時の通知は基本的には英語で届きます。
サービス名くらいは読めますし、最悪Google翻訳やDeepLに流せば良いのですが、サクッと翻訳したいなぁと思っていました。

調べると、こちらの方がすでにやりたいことを実現してくださっていました。

https://qiita.com/hotpepsi/items/3862618b38b463d37b53

ただ、これを導入しようとしたところ、いくつか問題が発生したため一部コードを修正しました。
今回はそのコードを公開する目的で記事を書きます。
(主にSlackの仕様変更の対応を加えた形)

動作の概要

以下のような動作をするbotを作ります。

  • 翻訳をしたいチャンネルにbotを招待する
  • 翻訳したいメッセージに国旗の絵文字(🇺🇸🇯🇵🇨🇳)でリアクションを送ると、その言語に翻訳してスレッドに返信してくれる
  • スレッドの途中でも利用できる
  • 主要な言語(日本語、英語、中国語、韓国語、ドイツ語、スペイン語、フランス語、イタリア語、ロシア語)に対応

なお、弊社オフショア先がミャンマーなので、ミャンマー語も追加しています。

動作イメージ
動作イメージ

ソースコード

ソースコードは以下の通りです。

ソースコード
script.gs
var TOKEN = PropertiesService.getScriptProperties().getProperty("TOKEN");
const flag_map = {
  "cn": { translateTo: "zh", languagePrefix: ":cn:" }, // 中国:中国語
  "flag-cn": { translateTo: "zh", languagePrefix: ":cn:" }, // 中国:中国語
  "de": { translateTo: "de", languagePrefix: ":de:" }, // ドイツ:ドイツ語
  "flag-de": { translateTo: "de", languagePrefix: ":de:" }, // ドイツ:ドイツ語
  "es": { translateTo: "es", languagePrefix: ":es:" }, // スペイン:スペイン語
  "flag-es": { translateTo: "es", languagePrefix: ":es:" }, // スペイン:スペイン語
  "fr": { translateTo: "fr", languagePrefix: ":fr:" }, // フランス:フランス語
  "flag-fr": { translateTo: "fr", languagePrefix: ":fr:" }, // フランス:フランス語
  "gb": { translateTo: "en", languagePrefix: ":gb:" }, // イギリス:英語
  "flag-gb": { translateTo: "en", languagePrefix: ":gb:" }, // イギリス:英語
  "it": { translateTo: "it", languagePrefix: ":it:" }, // イタリア:イタリア語
  "flag-it": { translateTo: "it", languagePrefix: ":it:" }, // イタリア:イタリア語
  "jp": { translateTo: "ja", languagePrefix: ":jp:" }, // 日本:日本語
  "flag-jp": { translateTo: "ja", languagePrefix: ":jp:" }, // 日本:日本語
  "kr": { translateTo: "ko", languagePrefix: ":kr:" }, // 韓国:韓国語
  "flag-kr": { translateTo: "ko", languagePrefix: ":kr:" }, // 韓国:韓国語
  "ru": { translateTo: "ru", languagePrefix: ":ru:" }, // ロシア:ロシア語
  "flag-ru": { translateTo: "ru", languagePrefix: ":ru:" }, // ロシア:ロシア語
  "us": { translateTo: "en", languagePrefix: ":us:" }, // ロシア:ロシア語
  "flag-us": { translateTo: "en", languagePrefix: ":us:" }, // ロシア:ロシア語
  "flag-mm": { translateTo: "my", languagePrefix: ":flag-mm:" }, // ミャンマー:ミャンマー語
}

function doPost(e) {
  try {
    var json = JSON.parse(e.postData.getDataAsString());
    if (json.type == "url_verification") {
      return ContentService.createTextOutput(json.challenge);
    }
    // https://api.slack.com/events/reaction_added
    // scope: "reactions:read"
    if (json.type == "event_callback" && json.event.type == "reaction_added") {
      return ContentService.createTextOutput(onReactionAdded(json.event));
    }
  } catch (ex) {
    console.log(ex);
  }
}

// https://api.slack.com/methods/chat.postMessage
// scope: "chat:write:user" or "chat:write:bot"

function postThreadMessage(channel, ts, text) {
  if (channel && ts && text) {
    var headers = {
      "Content-Type": "application/json",
      "Authorization": "Bearer " + TOKEN
    };
    var payload = {
      "token": TOKEN,
      "channel": channel,
      "thread_ts": ts,
      "text": text
    }
    var options = {
      "headers": headers,
      "method": "post",
      "payload": JSON.stringify(payload)
    };
    try {
      var response = UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", options);
      console.log(JSON.parse(response.getContentText()));
    } catch (ex) {
      console.log(ex);
    }
  }
}

// https://api.slack.com/methods/conversations.replies
// "channels:history" or "groups:history"

function getMessages(channel, ts) {
  var headers = {
    "Authorization": "Bearer " + TOKEN
  };
  var options = {
    "headers": headers
  };

  var response = UrlFetchApp.fetch("https://slack.com/api/conversations.replies?channel=" + channel + "&ts=" + ts, options);
  var json = JSON.parse(response.getContentText());
  return json.messages;
}

function isTranslated(messages, lang) {
  for (var i in messages) {
    var message = messages[i];
    if (message.text.substring(0, lang.length) == lang) {
      return true;
    }
  }
  return false;
}

function onReactionAdded(json) {
  var channel = json.item.channel;
  var type = json.item.type;
  var ts = json.item.ts;
  var reaction = json.reaction;
  if (type == "message") {
    var messages = getMessages(channel, ts);
    if (messages) {
      if (messages[0].thread_ts) {
        ts = messages[0].thread_ts;
      }
      var message = messages[0].text;
      if (flag_map[reaction] && !isTranslated(messages, flag_map[reaction].languagePrefix)) {
        var translatedMessage = flag_map[reaction].languagePrefix + " " + LanguageApp.translate(message, "", flag_map[reaction].translateTo);
        postThreadMessage(channel, ts, translatedMessage);
      }
    }
  }
  return "OK";
}

元のQiitaの記事からは、Postメッセージの方法の変更(API呼び出しが変わった?)や、言語をオブジェクトから取得できるようにしたり、元記事のままだとリプライの翻訳がおかしくなってしまっていたので、リプライへのリアクションでもちゃんと動くように変更したりしました。

bot作成手順

以下の手順で作成します。

  1. Google Apps Scriptで新しいプロジェクトを作成する
  2. 上記ソースコードを貼り付けて保存する
  3. 「デプロイ」→「新しいデプロイ」を開く
  4. 種類の選択の「ウェブアプリ」を選択し、「アクセスできるユーザー」で「全員」を選びデプロイする(URLをコピーしておく)。
  5. Slackのアプリの管理画面を開く(Slackデスクトップアプリだと、「ワークスペース」→「その他管理項目」→「アプリを管理する」)
  6. 右上の「ビルド」を開く
  7. 「Create New App」を実行
  8. 「Event Subscriptions」を開いて「Enable Events」を「On」にする
  9. 「Request URL」に4でコピーしたURLを貼り付ける
  10. 「Subscribe to bot events」で「Add Bot User Event」から「reaction_added」を追加し、「Save Changes」をクリックして保存
  11. 「OAuth & Permissions」を開き、「OAuth Tokens for Your Workspace」の「Bot User OAuth Token」をコピーする
  12. GASのコードエディタに戻り、旧エディタからスクリプトプロパティに「TOKEN」として11の「xoxb-〜〜〜」を設定する。(参考:Google Apps Scriptでのプロパティ設定方法は一番「ベタ」なやり方で
  13. 11のSlackのアプリ画面に戻り、「Scopes」で「Add an OAuth Scope」から以下の3つを追加する
    ・channels:history
    ・chat:write
    ・reactions:read
  14. 少し上に戻り、「Reinstall to Workspace」からワークスペースにアプリを追加する

これでおそらくいけるはずです。
あとはSlackアプリ名やアイコンなどは適宜変えてください。

割と試行錯誤した部分もあるので、記述漏れがあるかもしれません。
うまくいかないところがあればコメントなどで教えてください。

やれてないこと

セキュリティの考慮

セキュリティ的には微妙です。
誰でも実行できてしまう状態になっています。
「Verification Token」くらいは対応した方が良さそうです。

Slack3秒ルールの対応

SlackはAPIの呼び出しに3秒以内に応答するようにというルールがありますが、それにちゃんと対応してません。
元記事では同じメッセージを送ってしまわないようにチェックをかけることで対応していました。
本当はこちらの記事あたりを使って、翻訳処理は別キューにすべきです。

https://zenn.dev/katzumi/articles/58354fb4d05038

とはいえ、今のところそんなに遅延することもないので、問題は起きていません。

終わり

以上、Slackで翻訳してくれるBotを作る方法でした。

Discussion