【スレッド返信対応版】ChatGPTのDiscordBotを作ってデプロイまでやっちゃうの巻(discord.js & ChatGPT API & fly.io)

## はじめに
お疲れ様です！ @Keichan_15 です！

半年ほど前に以下の記事でChatGPT APIを使用したSlackBotの作成方法について公開しました。

https://qiita.com/Keichan_15/items/a4facca87d178f356397

で、最近そのSlackBotで事件が起きたんですよね～～ ~~僕のせいなんですが~~

とあるSlackのチャンネルにBotを導入して半年くらいずーっと運用していたのですが、諸事情あってプライベートチャンネルに変更した瞬間に返信がピタリと止んでしまったのです。

なんで～って思って調べてみたら、普通にSlack公式が以下のページで **「パブリックチャンネルだけしか使えんで！」** って書いてたみたいで、完全に見落としてました。嘘やん…。

https://api.slack.com/legacy/custom-integrations/outgoing-webhooks

> _Outgoing Webhooks are a legacy method of sending notifications to an app about two specific activities:_
> _1. A message was posted in a particular **public Slack channel.**_

ChatGPT APIのクレジットも持て余しちゃってる & 最近遂にGPT-4が使用できるようになったので、何とか活かせないかと考慮した結果、**Discord Botとして第2の人生を歩み始めるようにしてあげるのが良いんじゃないか**という結論に…！:relaxed:

そこで今回は
- 特定のチャンネルにChatGPTのBotが**自動返信**
- 複数メンバーのメッセージ送信に対応する**スレッド返信**
- 継続運用のための**デプロイ**

の豪華三本立てでお送りしていきます！

~~スレッド返信の日本語資料が少なすぎて本当に困難を極めました…マジツラカッタ～～~~

それではやっていきましょう！！:v:

## 環境
- WSL2(Ubuntu 20.04.5)
- Node.js(v16.18.0)
- npm(v8.19.2)
- Visual Studio Code(v1.83.1)

当初はおもちゃを動かすくらいの想定だったので、昔にRails環境を構築した時に一緒に入れたNode.jsが入ってたWSL2を選びました。

ぶっちゃけNode.jsが動かせる環境なら何でも良いと思います。

## 環境準備
まず任意のディレクトリを作成します。本記事では`chat_gpt`にしました。何でも良いです。

作成したディレクトリに移動後、以下のコマンドを入力して`package.json`を生成します。

```terminal:terminal
$ npm init -y
```

以下のような`package.json`が生成されます。

```json:package.json
{
  "name": "chat_gpt",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
```

今回は`index.js`を`bot`というフォルダ内で管理するようにするため、`package.json`を以下のように書き換えましょう。(ついでにモジュール環境でimportステートメントを使えるようにもしておきます)

```diff_json:package.json
{
  "name": "chat_gpt",
  "version": "1.0.0",
  "description": "",
- "main": "index.js",
+ "main": "bot/index.js",
+ "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC"
}
```

同時に`bot`ディレクトリを作成し、`index.js`も作成します。
この時点でディレクトリ構成が以下のようになっていれば問題ありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/b11c1227-312d-4462-fdae-fd89b49d66d4.png)

次に以下の3つをインストールするため、以下のコマンドを入力します。

- `discord.js`
- `dotenv`(.env 環境変数読み込み用のモジュール)
- `openai`

```terminal:terminal
$ npm install discord.js dotenv openai
```

次に環境変数設定用の`.env`ファイルを作成します。

```terminal:terminal
$ touch .env
```

これで準備は一旦完了です。現時点のディレクトリ構成が以下のようになっていれば問題ありません！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/e74a2c25-3a69-0de0-57b0-d01c225e971d.png)

`package.json`も以下のように更新されていると思います。

```json:package.json
{
  "name": "chat_gpt",
  "version": "1.0.0",
  "description": "",
  "main": "bot/index.js",
  "type": "module",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "discord.js": "^14.13.0",
    "dotenv": "^16.3.1",
    "openai": "^4.14.1"
  }
}
```

次にBot側の設定をしていきましょう！

## Bot準備
[Discord Developer Portal](https://discord.com/developers/applications)にアクセスします。(ログインしてない場合はログインしてくださいね！)

https://discord.com/developers/applications

画面右上の`New Application`をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/7e151345-0cfe-f028-c88f-a85abcc85365.png)

`NAME`の欄にBot名を入力し、チェックボックスにチェックを入れて`Create`をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/a2f0e8e0-18c1-323d-99e8-89cf93ad8af3.png)

サイドバーの`settings`から`bot`をクリックし、各設定を以下のように変更しましょう。
変更後は`Save Changes`をお忘れなく！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/8f84b31b-fce6-0191-d83e-1024c83e2a91.png)

次に`Build-A-Bot`の`TOKEN`欄にある`Reset Token`をクリックしましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/b21e0e03-434b-5686-dceb-362d130cf12b.png)

`Yes, do it！`をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/80c7f433-bb4a-cb5d-9311-5c57894e88df.png)

表示されたTOKENをコピーしてください。**後ほど使用しますのでどこかにメモしておいてくださいね！**

次にサイドバーの`OAuth2` > `URL Generator`をクリックします。
`SCOPES`と`BOT PERMISSIONS`の設定項目があるので、以下のようにチェックを入れてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/2c949969-be5a-4bef-1d84-78557636ed31.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/d09e8db5-07cb-1e2c-7c15-177069cfb805.png)

チェックが終わったら画面の一番下に表示されているURLをコピーしてブラウザに貼り付けましょう。
以下のような画面が表示されるので、Botを導入したいサーバーを選択して`はい`をクリックします。

![スクリーンショット 2023-10-28 163345.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/28167fb7-862f-a33e-1b16-a76643b2fe0a.png)

`認証`をクリック。

![スクリーンショット 2023-10-28 163644.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/38fb7113-eacf-6f9e-5672-cd05460ebb92.png)

Botがサーバーに入ったことを確認しましょう！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/9e1603e5-e1be-92ad-f767-219efdc2463c.png)

## OpenAI APIキー取得
Open AIのAPIキー取得方法については、自身が過去に執筆した以下の記事にて紹介しています。

https://qiita.com/Keichan_15/items/a4facca87d178f356397#1-2-openai-%E3%81%AEapi%E3%82%AD%E3%83%BC%E5%8F%96%E5%BE%97

本記事では取得方法の詳細については割愛させて頂きます。
こちらもDiscordBotと同様、取得したAPIキーは**忘れないようメモ**しておいてくださいね。

## .envの編集
環境変数を設定します。`.env`に以下の記述を追加してください。

```.env
TOKEN=DiscordBotのTOKEN
OPENAI_API_KEY=OpenAIのAPIキー
CHANNEL_ID=Botが返信を送信するチャンネルのID
```
`CHANNEL_ID`の確認方法ですが、導入したいテキストチャンネルを右クリックすると`チャンネルIDをコピー`のボタンが出てきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/9dd6b0ba-806d-ee42-4ff2-5ad73dbf8d70.png)

## index.jsの編集
まず、特定のチャンネルにメッセージが送信された場合に、そのチャンネルにBotがそのまま返信するようにします。

`index.js`に以下のコードを設定してください。

```javascript:bot/index.js
import { Client, GatewayIntentBits } from 'discord.js';
import dotenv from 'dotenv';
import OpenAI from 'openai';

// dotenv適用(これで.envに記載の環境変数を使用できる)
dotenv.config()

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const client = new Client({ intents: [
  GatewayIntentBits.Guilds,
  GatewayIntentBits.MessageContent,
  GatewayIntentBits.GuildMessages,
  GatewayIntentBits.GuildPresences,
  GatewayIntentBits.GuildMembers,
] });

client.once('ready', async () => {  

  // 開始ログ
  console.log('ready!');
});

client.on('messageCreate', async (message) => {

  // メッセージの送信者がBotの場合 or 特定のチャンネル以外からのメッセージ送信の場合はreturn
  if (message.author.bot || message.channel.id != process.env.CHANNEL_ID) return;

  try {
    // 送信されたメッセージをpromptに設定
    const completion = await openai.chat.completions.create({
      messages: [{ role: 'user', content: `${message.content}`}],
      model: 'gpt-4',
    });

    if (completion.choices[0].message.content === undefined) throw new Error();
    
    // メッセージが送信されたチャンネルに、GPT-4の返信を送信
    await message.channel.send(completion.choices[0].message.content);
  } catch (err) {
    console.log(err);
  };
});

client.login(process.env.TOKEN);
```

コードの内容は基本的にコメント通りの内容になっています。



何せ日本語資料が少なすぎるので、ドキュメントやGitHubと睨めっこする時間が多かったな～という感じです。

https://github.com/openai/openai-node

https://discord-api-types.dev/api/discord-api-types-v10/enum/GatewayIntentBits

注意点としては今回の記事で使用しているdiscord.jsのバージョンは**14**になっています。
13との大きな変更点にIntentsの書き方が変わっている所が挙げられるみたいです。 ~~ぶっちゃけ追えてない~~

https://qiita.com/tonnatorn/items/106fa502272196780e6b

13までは`FLAGS.GUILD_MESSAGES`のような書き方だったのが、14で`GatewayIntentBits.MessageContent`のような書き方に変わっているようです。

ただこれから導入するのであれば基本的に`v14`になると思うので、あまり気にしなくても良い所かなと思います。
13→14へのバージョンアップが伴う場合は要注意ポイントになりますね。

## 動作確認
`index.js`の編集が終わったらローカルで一度Botを動作させてみましょう。

ターミナルに以下のコマンドを入力します。

```terminal:terminal
$ node bot/index.js
```

Botの返信先に設定したチャンネル内でメッセージを入力すると、Botから返信が返ってくることが確認できました！:clap:

![スクリーンショット 2023-10-28 165724.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/4cfcdb6c-ad44-9b53-b168-7e04075c318a.png)

## スレッド返信に対応する
現段階でもChatGPTのBotとして十分な働きをしてくれているのですが、当初の想定ではこのチャンネルに参加しているメンバーなら誰でも使用できるようにしたいと考えていました。

~~案の定、Botを解禁した瞬間、治安がドエライことに…:innocent:~~

そこで**各メッセージに対してスレッドを作成することで、どのメッセージに対する返信なのかを分かりやすくするようにしてみたい**と考えたわけです。

~~これが魔境・地獄の始まりなのですが~~ 完成版から見ていきましょう。:relaxed:
`index.js`を以下のコードに書き換えてください。

```diff_javascript:bot/index.js
import { Client, GatewayIntentBits } from 'discord.js';
import dotenv from 'dotenv';
import OpenAI from 'openai';

dotenv.config()

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const client = new Client({ intents: [
  GatewayIntentBits.Guilds,
  GatewayIntentBits.MessageContent,
  GatewayIntentBits.GuildMessages,
  GatewayIntentBits.GuildPresences,
  GatewayIntentBits.GuildMembers,
] });

client.once('ready', async () => {  
  console.log('ready!');
});

client.on('messageCreate', async (message) => {

  // メッセージの送信者がBotの場合 or 特定のチャンネル以外からのメッセージ送信の場合はreturn
  if (message.author.bot || message.channel.id != process.env.CHANNEL_ID) return;

  try {
    // 送信されたメッセージをpromptに設定
    const completion = await openai.chat.completions.create({
      messages: [{ role: 'user', content: `${message.content}`}],
      model: 'gpt-4',
    });

    if (completion.choices[0].message.content === undefined) throw new Error();

+   const thread = await message.startThread({
+     name: message.content,
+     autoArchiveDuration: 60,
+   })

+   await thread.send(completion.choices[0].message.content);
-   await message.channel.send(completion.choices[0].message.content);
    
  } catch (err) {
    console.log(err);
  };
});

client.login(process.env.TOKEN);
```

該当箇所にコードを追加・削除して再度Botを起動すると、以下のように送信したメッセージに対してスレッドを作成し、スレッド内でBotが返信していることを確認できると思います！:raised_hands:

![スクリーンショット 2023-10-28 173344.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/2a76cab9-74a0-235d-c71a-b7aa3fdc72fd.png)

### 解説

追加を行った箇所について補足します。

```javascript
    const thread = await message.startThread({
      ...
    })
```

`Threads`の`Message#startThread()`は既存メッセージに対してスレッドを作成することができます。
discordのスレッド作成方法は主に以下の2点です。

- チャンネルにスレッドを新規作成する
- メッセージに対してスレッドを新規作成する

今回は送信されたメッセージに対してスレッドを作成したかったため、`Message#startThread()`を使用しました。

```javascript
      name: message.content,
      autoArchiveDuration: 60,
```
`name:`はスレッドのタイトルを設定することができます。今回は送信されたメッセージをタイトルに設定するようにしました。

`autoArchiveDuration:` は自動アーカイブを行う時間を設定します。

その他詳しい内容についてはdiscord.js Guideに細かい記載があるので、ご興味ある方はご覧になられることをおススメします。

https://discordjs.guide/popular-topics/threads.html#threads

このスレッド返信ですが、本当に魔境で日本語資料が少なすぎてしんどかった…。ツライ。
公式ドキュメント(英語)って正義なんやなって。:thinking:

## fly.ioにデプロイして継続運用する
ちなみにここまで作成したBotですが、現状段階ではローカルで起動している間のみBotが動作するため、サーバーを停止してしまうとBotも一緒に停止してしまいます。

そのため**Botを継続運用するためにはデプロイが必要**になるんですね。

Slack Botの際はGASでデプロイするだけで動作したのですが、DiscordBotはGASでは自動返信を行うことができないんですね…。面倒だな…。

なるべくお金を掛けたくない乞食マンなので、今herokuの代替先としてアツい`fly.io`を今回採用しました。:fire:

https://fly.io/

`fly.io`の個人的に良いポイントは、CLIで基本的にデプロイまで完結してしまうという点と東京リージョンが標準搭載されている点です。いいねえ。:sunglasses:

まず`flyctl`をインストールします。

```terminal:terminal
$ curl -L https://fly.io/install.sh | sh
```

OSによってインストールコマンドが変わるため、以下のページでご自身のOSに沿ったコマンドを入力してください。

https://fly.io/docs/hands-on/install-flyctl/

次にサインアップ・サインインを行います。自動で画面が開くので、案内に沿って進めていってください。

```terminal:terminal
$ flyctl auth signup
$ flyctl auth login
```

ログインが完了したら以下のコマンドを入力し、質問内容に沿ってコマンドを使用して回答していきます。

`fly.launch`は対話形式でデプロイを自動化するためのコマンドです。

```terminal:terminal
$ fly launch

...

# アプリケーション名を入力(お好きな名前でOK!)
? Choose an app name (leave blank to generate one): keichan-gpt-bot

...

# リージョンを選択(東京リージョンでOK)
? Choose a region for deployment: Tokyo, Japan (nrt)
App will use 'nrt' region as primary

...

Created app 'keichan-gpt-bot' in organization 'personal'

...

If you need custom packages installed, or have problems with your deployment
build, you may need to edit the Dockerfile for app-specific changes. If you
need help, please post on https://community.fly.io.

Now: run 'fly deploy' to deploy your Node.js app.
```
``Now: run 'fly deploy' to deploy your Node.js app.`` の表示が出ていればOKです。
ちなみに実行後のディレクトリを確認すると、以下の3つのファイルが追加されていると思います。

- `.dockerignore`
- `Dockerfile`
- `fly.toml`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/5a2075a7-655c-3e34-fc67-2e4557008862.png)

次に`fly.toml`を開き、以下のように編集します。

デフォルトでは`http service`を使用するようになっているため、今回のBot用に変更を加える必要があります。

```diff_toml:fly.toml
# fly.toml app configuration file generated for keichan-gpt-bot on 2023-10-28T18:20:20+09:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#

app = "keichan-gpt-bot"
+ primary_region = "nrt"
+ kill_signal = "SIGINT"
+ kill_timeout = 5
+ processes = []

+ [env]

- primary_region = "nrt"

- [build]

- [http_service]
-  internal_port = 3000
-  force_https = true
-  auto_stop_machines = true
-  auto_start_machines = true
-  min_machines_running = 0
-  processes = ["app"]
```

最後に環境変数を設定します。`fly.io`は環境変数の登録がCLIのみでしかできません。

```terminal:terminal
$ flyctl secrets set TOKEN=DiscordBotのTOKEN OPENAI_API_KEY=OpenAIのAPIキー CHANNEL_ID=Botが返信を送信するチャンネルのID
```

環境変数が登録できているかは以下のコマンドで確認できます。

```terminal:terminal
$ flyctl secrets list
NAME            DIGEST                  CREATED AT
CHANNEL_ID      7e98152c44c44309        21s ago
OPENAI_API_KEY  6080ebcf755537aa        21s ago
TOKEN           5eee98f021cbafb8        21s ago
```

最後にデプロイをしましょう。以下コマンドを入力します。

```terminal:terminal
$ fly deploy
...

No machines in group app, launching a new machine
Creating a standby machine for 32874ddec54d78
Finished launching new machines
-------
 ✔ Machine 91857559c53178 [app] was created
-------
```

上記のような表記が出ていればデプロイは成功です！

ダッシュボードにアクセスするとデプロイしたbotの名前が表示されていることが確認できます。

https://fly.io/dashboard/

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/9ec87952-be63-708e-402e-f6d9d7056dbf.png)

デプロイが問題無く成功しているかは`Monitoring`の画面で確認しましょう。

デプロイ後にアプリケーションが上手く動作しない場合は、この`Monitoring`を確認することでエラーの特定につながります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2798073/c20a7cca-0824-f181-632f-d41f69e83180.png)

## 参考
https://qiita.com/Pumila/items/f134f210789842340066

https://qiita.com/n4rvs3_squld/items/a14afbbcf4bdc02a51a4

https://github.com/openai/openai-node

https://discord-api-types.dev/api/discord-api-types-v10/enum/GatewayIntentBits

https://discordjs.guide/popular-topics/threads.html#threads

## おわりに
いかがでしたでしょうか。
今回はdiscord.jsとChatGPT APIを使用したDiscordBotを作成し、fly.ioを使用してデプロイまで行いました。

プログラミングを学ぶ上で個人的におススメなのは、こういう~~しょうもない~~おもちゃを興味本位で作り始めるのが一番良いのかなと思います。何事も興味が無いとやる気って出ないですし。:zipper_mouth:

そういえばそろそろQiita Advent Calendar 2023の季節がやってきますね。楽しみ～～。

ではまた！:wave: