slackで日英同時投稿コマンド(ChatGPT+Node.js)

自分向け備忘録です。

slackで、日本語と英語を両方同時に投げる場合にいちいち翻訳したり自分で両方文章作るのだるかったので、片方入れたらもう一方は自動で翻訳して投稿してくれるコマンドを作りました。

Slackアプリを作ったり、権限を付与する必要があります。この辺は適当に設定を。（今回は省略）


必要なライブラリをインストールします。npm initを実行してpackage.jsonを生成し、以下のコマンドでパッケージをインストールします。

```
$ npm install @slack/bolt @slack/web-api axios
```

内容としては、slackコマンドを入れると、モーダルが出てきて、そこに翻訳したい内容を入れてsubmitすると、同時翻訳されて投稿される、という形。

index.jsに下記を記載。

```index.js
const {App, ExpressReceiver} = require('@slack/bolt');
require("dotenv").config();
const axios = require('axios');

const expressReceiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  processBeforeResponse: true,
});

const app = new App({
    receiver: expressReceiver,
    token: process.env.SLACK_BOT_TOKEN,
    processBeforeResponse: true,
    scopes: ['users.profile:read', 'users:read']
});

app.command("/honyaku-post-ja-to-en", async ({ack, body, client, logger}) => {
    try {
        await ack();

        const view = {
            type: 'modal',
            callback_id: 'honyaku_modal',
            title: {
              type: 'plain_text',
              text: '翻訳',
            },
            submit: {
              type: 'plain_text',
              text: 'Submit',
            },
            close: {
              type: 'plain_text',
              text: 'Cancel',
            },
            blocks: [
              {
                type: 'input',
                block_id: 'text_input',
                label: {
                  type: 'plain_text',
                  text: '日本語で文章を入力してください',
                },
                element: {
                  type: 'plain_text_input',
                  action_id: "input_text",
                },
              },
            ],
            private_metadata: JSON.stringify({
                channel_id: body.channel_id,
                event_type: 'ja_to_en'
            }),
          };

        const result = await client.views.open({
            trigger_id: body.trigger_id,
            view: view,
          });
      
        logger.info(result);
    } catch (error) {
        console.log("error");
        console.error(error);
    }
});

app.command("/honyaku-post-en-to-ja", async ({ack, body, client, logger}) => {
  try {
      await ack();

      const view = {
          type: 'modal',
          callback_id: 'honyaku_modal',
          title: {
            type: 'plain_text',
            text: 'Translation',
          },
          submit: {
            type: 'plain_text',
            text: 'Submit',
          },
          close: {
            type: 'plain_text',
            text: 'Cancel',
          },
          blocks: [
            {
              type: 'input',
              block_id: 'text_input',
              label: {
                type: 'plain_text',
                text: 'Please enter the text in English.',
              },
              element: {
                type: 'plain_text_input',
                action_id: "input_text",
              },
            },
          ],
          private_metadata: JSON.stringify({
              channel_id: body.channel_id,
              event_type: 'en_to_ja'
          }),
        };

      const result = await client.views.open({
          trigger_id: body.trigger_id,
          view: view,
        });
    
      logger.info(result);
  } catch (error) {
      console.log("error");
      console.error(error);
  }
});


app.view("honyaku_modal", async ({ack, body, view, client, logger}) => {
    try {
        await ack();
        const privateMetadata = JSON.parse(view.private_metadata);
        const channelId = privateMetadata.channel_id;
        const eventType = privateMetadata.event_type;

        const inputText = view["state"]["values"]["text_input"]["input_text"]["value"];

        const translatedText = await translateText(eventType, inputText);

        const userInfo = await client.users.info({
            user: body.user.id,
        });

        const displayName = userInfo.user.profile.display_name || userInfo.user.profile.real_name;
        const iconUrl = userInfo.user.profile.image_72 || userInfo.user.profile.image_original;

        

        const result = await client.chat.postMessage({
            channel: channelId,
            username: displayName,
            icon_url: iconUrl,
            text: `${translatedText}\n\n${inputText}`,
        });
        logger.info(result);
    } catch (error) {
        console.error(error);
    }
});

app.command("/honyaku-post-help", async ({ack, body, client, logger}) => {
  try {
    ack();

    const result = await client.chat.postMessage({
      channel: body.channel_id,
      text: `使い方\n/honyaku-post-ja-to-en: 日本語を英語に翻訳して同時投稿します。\n/honyaku-post-en-to-ja: 英語を日本語に翻訳して同時投稿します。\n/honyaku-post-help: 使い方を表示します。\nUsage\n/honyaku-post-ja-to-en: Translates Japanese to English and posts simultaneously.\n /honyaku-post-en-to-ja: Translates English to Japanese and posts simultaneously.\n /honyaku-post-help: Displays usage instructions.`,
    });
    logger.info(result);
  } catch (error) {
    console.error(error);
  }
});

async function translateText(eventType, text) {
    const apiKey = process.env.OPENAI_API_KEY;
    const apiEndpoint = "https://api.openai.com/v1/chat/completions";

    try {
        let prompt = "";
        if (eventType == "ja_to_en") {
          prompt = `Translate the following Japanese text into English. No explanation is needed. Please only provide the translated text.: ${text}.`;
        } else {
          prompt = `Translate the following English text into Japanese. No explanation is needed. Please only provide the translated text.: ${text}`;
        }
        const response = await axios.post(
            apiEndpoint,
            {
                messages: [{"role": "user", "content": prompt}],
                model: "gpt-3.5-turbo"
            },
            {
                headers: {
                    "Content-Type": "application/json",
                    "Authorization": `Bearer ${apiKey}`,
                },
            },
        );

        return response.data.choices[0].message.content;
    } catch (error) {
        console.error(error);
        return null;
    }
}


function isOnGoogleCloud() {
  // https://cloud.google.com/functions/docs/env-var#nodejs_10_and_subsequent_runtimes
  return process.env.K_SERVICE && process.env.K_REVISION;
}
if (!isOnGoogleCloud()) {
  // Running on your local machine
  (async () => {
    const port = 3000;
    await app.start(process.env.PORT || port);
    console.log('⚡️ Slack app is running!');
  })();
}

exports.jaenSlackCommand = expressReceiver.app;
```

.envにkey系を入れておく
```
SLACK_SIGNING_SECRET="XXXX"
SLACK_BOT_TOKEN="XXXX"
SLACK_APP_TOKEN="XXXX"
OPENAI_API_KEY="XXXX"
```

```package.json
{
  "name": "sample-slack-action",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "nodemon index.js"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@google-cloud/functions-framework": "^3.2.0",
    "@slack/bolt": "^3.13.1",
    "axios": "^1.4.0",
    "dotenv": "^16.0.3",
    "nodemon": "^2.0.22"
  }
}
```

slack APPの設定をやった上で、ローカルで、立ち上げて試す。localhost立ち上げつつ、ngrokでslackアプリから見れるようにする

```
# localhost立ち上げた上で、
$ npm run dev
# ngrok使う
$ ngrok http 3000
```
ngrokで出てきたURLをslack側のslack commandとinteractivity&shortcutのところに設定する。（[URL]/slack/eventsの形で入れる）

これで、叩けるようになってるはず。

---

で、モーダルでやったのは良いが、モーダル無しで、コマンド + メッセージで良いので、そちらに改修

```index.js
const {App, ExpressReceiver} = require('@slack/bolt');
const functions = require('@google-cloud/functions-framework');
require("dotenv").config();
const axios = require('axios');

const expressReceiver = new ExpressReceiver({
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  processBeforeResponse: true,
});

const app = new App({
    receiver: expressReceiver,
    token: process.env.SLACK_BOT_TOKEN,
    processBeforeResponse: true,
    scopes: ['users.profile:read', 'users:read']
});

app.command("/honyaku-post-ja-to-en", async ({ack, body, client, logger}) => {
    try {
        await ack();

        const channelId = body.channel_id;
        const eventType = 'ja_to_en';

        const inputText = body.text;
        console.log('body', body);
        console.log('inputText', inputText);

        if (!inputText || inputText.trim().length === 0) {
          await client.chat.postMessage({
            channel: channelId,
            text: "コマンドの後に翻訳したいメッセージを入力してください",
          });
          return;
        }

        const translatedText = await translateText(eventType, inputText);

        const userInfo = await client.users.info({
            user: body.user_id,
        });

        const displayName = userInfo.user.profile.display_name || userInfo.user.profile.real_name;
        const iconUrl = userInfo.user.profile.image_72 || userInfo.user.profile.image_original;

        const result = await client.chat.postMessage({
          channel: channelId,
          username: displayName,
          icon_url: iconUrl,
          text: `${translatedText}\n\n${inputText}`,
        });
        logger.info(result);
    } catch (error) {
        console.log("error");
        console.error(error);
    }
});

app.command("/honyaku-post-en-to-ja", async ({ack, body, client, logger}) => {
  try {
      await ack();

      const channelId = body.channel_id;
      const eventType = 'en_to_ja';

      const inputText = body.text;

      if (!inputText || inputText.trim().length === 0) {
        await client.chat.postMessage({
          channel: channelId,
          text: "コマンドの後に翻訳したいメッセージを入力してください",
        });
        return;
      }

      const translatedText = await translateText(eventType, inputText);

      const userInfo = await client.users.info({
          user: body.user_id,
      });

      const displayName = userInfo.user.profile.display_name || userInfo.user.profile.real_name;
      const iconUrl = userInfo.user.profile.image_72 || userInfo.user.profile.image_original;

      const result = await client.chat.postMessage({
        channel: channelId,
        username: displayName,
        icon_url: iconUrl,
        text: `${translatedText}\n\n${inputText}`,
      });
      logger.info(result);
      
  } catch (error) {
      console.log("error");
      console.error(error);
  }
});


app.command("/honyaku-post-help", async ({ack, body, client, logger}) => {
  try {
    ack();

    const result = await client.chat.postMessage({
      channel: body.channel_id,
      text: `使い方\n/honyaku-post-ja-to-en: 日本語を英語に翻訳して同時投稿します。\n/honyaku-post-en-to-ja: 英語を日本語に翻訳して同時投稿します。\n/honyaku-post-help: 使い方を表示します。\nUsage\n/honyaku-post-ja-to-en: Translates Japanese to English and posts simultaneously.\n /honyaku-post-en-to-ja: Translates English to Japanese and posts simultaneously.\n /honyaku-post-help: Displays usage instructions.\n\n※ slackコマンドの仕様で、応答に時間がかかると、エラーと返ってくることが、特に支障はありません。\nIn the specifications of the slack command, there is no problem if an error is returned when the response takes time.
      `,
    });
    logger.info(result);
  } catch (error) {
    console.error(error);
  }
});

async function translateText(eventType, text) {
    const apiKey = process.env.OPENAI_API_KEY;
    const apiEndpoint = "https://api.openai.com/v1/chat/completions";

    try {
        let prompt = "";
        if (eventType == "ja_to_en") {
          prompt = `Translate the following Japanese text into English. No explanation is needed. Please only provide the translated text.: ${text}`;
        } else {
          prompt = `Translate the following English text into Japanese. No explanation is needed. Please only provide the translated text.: ${text}`;
        }
        const response = await axios.post(
            apiEndpoint,
            {
                messages: [{"role": "user", "content": prompt}],
                model: "gpt-3.5-turbo"
            },
            {
                headers: {
                    "Content-Type": "application/json",
                    "Authorization": `Bearer ${apiKey}`,
                },
            },
        );

        return response.data.choices[0].message.content;
    } catch (error) {
        console.error(error);
        return null;
    }
}


function isOnGoogleCloud() {
  // https://cloud.google.com/functions/docs/env-var#nodejs_10_and_subsequent_runtimes
  return process.env.K_SERVICE && process.env.K_REVISION;
}
if (!isOnGoogleCloud()) {
  // Running on your local machine
  (async () => {
    const port = 3000;
    await app.start(process.env.PORT || port);
    console.log('⚡️ Slack app is running!');
  })();
}

exports.jaenSlackCommand = expressReceiver.app;
```

で、これで行けるかと思ったら、メンションをメッセージ内に入れると、user_nameに変換してしまうようで、これの対処をChatGPTさんに聞くと、

```
上記のコードで、
/honyaku-post-ja-to-en @maze 元気ですか？ とslackで入力した場合、@mazeがuser_nameで展開されてしまうのですが、どのように対応すると良いでしょうか？　@mazeのまま翻訳してほしいです

ChatGPT
Slackは特殊な構文（@usernameや#channel）を自動的にIDに変換するため、メッセージがAPIに到達する前に@usernameはすでにID形式に変換されてしまいます。

しかし、Slackはリクエストに"parse"フィールドを提供します。これに"none"を設定することで特殊な構文を自動的にパースする機能を無効化できます。ただし、これはAPIメソッドがこれをサポートしている場合のみ可能です。

残念ながら、Boltフレームワークのapp.commandメソッドはparseフィールドをサポートしていないため、この問題を解決することはできません。

他のアプローチとして、翻訳処理前にユーザーIDを@username形式に戻すことも考えられますが、これにはユーザーIDからユーザー名を取得するための追加のAPI呼び出しが必要になります。このため、この方法はAPI呼び出しの数を増やすことになり、パフォーマンスやレートリミットに影響を及ぼす可能性があります。

したがって、現在のところ理想的な解決策は存在しないと言えます。上記の制限を受け入れ、この問題をユーザーに説明するか、あるいは翻訳するテキストからユーザー名を除外するなど、異なるアプローチを検討する必要があります。
```
とのことで断念。Boltフレームワークがんばれ！

一旦、元のモーダルのtextfieldをもうちょい大きくして対応終了。

```index.js
{
  type: 'input',
  block_id: 'text_input',
  label: {
    type: 'plain_text',
    text: '日本語で文章を入力してください',
  },
  element: {
    type: 'plain_text_input',
    multiline: true,  // 追加
    action_id: "input_text",
  },
}
```


良いやり方ある人いれば教えてください。