Discord.pyとOpenAI APIで簡単なBotを作った

# はじめに  
OpenAI APIを使ってみたい一心と、開発終了の一件で離れていたDiscord.pyが再開してるのに今更気づいたので、これらを掛け合わせてリスキリングとOpenAI APIに対する知見を深めるためにサクッとBotを作ってみました。  

いざ手を進めてみると、Discord.pyの方面で欲しい情報があまり無いように感じたので、この記事は主にDiscord.pyを扱う際に自分が欲しかった内容に重点を置きつつ、これからBotを作る方へ向けた内容になります。  

# ゴール  
この記事で完成するBotは以下のような要件になります。
* コマンドはスラッシュコマンドとし、メッセージの読み取りは行わない
* コグとエクステンションを用いて、本体とコマンドを別ファイルに管理し、あとあとコマンドを追加し易くする
* Discord上で「/ask {任意の文言}」とコマンドを実行すると、それに対する回答をOpenAI APIを用いて作成し、回答する

こんな感じのものが出来上がります。
![完成品](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/06126d84-c500-7a86-fd90-3d56accd83aa.png)

# 前提条件
前提として、Botのトークンがすでに手元にある状態で進めます。Discord Developer Portal上での操作は解説しません。その手の記事は調べてください。サクッと出てきます。  

インストールしたパッケージと、各種バージョンは以下の通りです。
* Python 3.11.1
    * discord.py 2.2.2
    * openai 0.27.2 
    * python-dotenv 1.0.0

# コーディング
### 環境変数周り
メインのプログラムを書いていく前に、環境変数を扱うプログラムを書きます。
BotのトークンやAPIのキーなど外部に知られたく無い情報を別ファイルにまとめて、そこから取り出すようにしておきます。
```env:.env
DISCORD_TOKEN='[Discord Developer Portalから取得したBOTのトークン]'
SERVER_ID='[Botを動かすDiscordのサーバーID]'
OPENAI_API_KEY='[OpenAIのAPI　Key]'
```
```python:settings.py
from dotenv import load_dotenv
import os
from os.path import join, dirname

def base():
    dotenv_path = join(dirname(__file__), '.env')
    load_dotenv(dotenv_path)

def getToken():
    base()
    DIS_TOKEN = os.environ.get("DISCORD_TOKEN")
    return DIS_TOKEN

def getId():
    base()
    SERVER_ID = os.environ.get("SERVER_ID")
    return SERVER_ID

def getKey():
    base()
    API_KEY = os.environ.get("OPENAI_API_KEY")
    return API_KEY


if __name__=="__main__":
    print(getToken(),getId(),getKey())
```
一気にまとめて取得しても良かったのですが、後になってから分割しておいたほうが都合が良かったので、分割しました。
プログラムを書き終えたら一度実行して、すべて表示されるか確認しておきましょう。正常なことを確認したら、最後の2行は消しておくか、コメントアウトしておきます。

### メインプログラム
Botの起動や、存在するコマンドの集約を行う本体の部分です。
```python:main.py
import settings
import discord
from discord.ext import commands

INITAL_EXTENSIONS = [
    "cogs.ask",
]

class MyBot(commands.Bot):
    def __init__(self):
        super().__init__(
            command_prefix="!",
            intents=discord.Intents.all(),
        )
    
    async def setup_hook(self):
        for extension in INITAL_EXTENSIONS:
            await self.load_extension(extension)

if __name__ == '__main__':
    bot_token = settings.getToken()
    MyBot().run(bot_token)

```
特筆すべきは``INITAL_EXTENSIONS``というリストです。このリスト内に実装したいコマンドが書かれたファイル名を書き連ねていきます。
この場合は、``cogs``というフォルダ内にある``ask.py``のみを読み込んでいます。
```python:main.py
INITAL_EXTENSIONS = [
    "cogs.ask",
    "cogs.hoge",
    ...
]

```
このように増やしていくことで、あとからコマンドを増やしやすい利点があります。
これらは、``setup_hook``にて一つづつ読み込まれていきます。

### コマンドプログラム
/askコマンドが実行されたときの処理を記述していきます。
```python:cogs/ask.py
import discord
from discord.ext import commands
from discord import app_commands
import settings
import openai

def gpt(key,text):
    openai.api_key = key

    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "system", "content": "日本語で返して"},
            {"role": "user", "content": text},
        ]
    )
    return response["choices"][0]["message"]["content"]

class AskCog(commands.Cog):
    def __init__(self, bot: commands.Bot):
        super().__init__()
        self.bot = bot

    @commands.Cog.listener()
    async def on_ready(self):
        await self.bot.tree.sync(guild=discord.Object(int(settings.getId())))
        print("[Cogs] AskCog is ready.")

    @app_commands.command(
        name="ask",
        description="チャーミィになんでも質問できます。"
    )
    @app_commands.guilds(int(settings.getId()))
    async def ask(self, ctx:discord.Interaction, text: str):
        await ctx.response.defer()
        try:
            message = gpt(settings.getKey(),text)
        except Exception as e:
            message = "回答が見つからなかったか、内部でエラーが発生した可能性があります。"
            print(e)
        embed=discord.Embed(title=text, description=message, color=0xff9300)
        await ctx.followup.send(embed=embed)
        
async def setup(bot: commands.Bot):
    await bot.add_cog(AskCog(bot))
```
``AskCog``クラスでは、Bot起動時にDiscordに対してスラッシュコマンドの同期を行い、終了次第ターミナルにメッセージを表示させています。  
``@app_commands.command``内ではコマンドに関する情報を記述し、``@app_commands.guilds``内でその振る舞いについて記述しています。

一般的に、コマンドを実行してすぐ返答する場合は
```python
@app_commands.guilds(int(settings.getId()))
    async def hoge(self, ctx: discord.Interaction):
        await ctx.response.send_message("Hello World!")
```
このように、``ctx.response.send_message``を用いますが、この場合3秒ほど応答が無い場合は実行が中断されてしまいます。

ChatGPTを使ったことがある方ならお分かりかと思いますが、回答に3秒は余裕でかかりますので、実行が中断してしまうことは容易に想像できます。処理に時間がかかる場合は``response.defer()``を用いて一旦Discordに対して回答してしまった後に、``followup.send``を用いて２度目の回答を行います。

また、今回は回答の際にembed(埋め込み)を用いています。一種の装飾のようなもので、見た目を良くしています。これに関する記事は調べれば多く出てくるので割愛します。
![embedを用いたときと用いなかった時の比較](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3287582/8ddef113-e50c-a197-d855-7b020d552e0f.png)
上が純粋にテキストそのままで回答したとき、下がembedを使ったとき。回答が長文になることが多いので、こっちのほうが見やすいように思います。

``gpt``関数では、OpenAI APIを利用した処理を行っています。特筆するようなことはありませんが、レスポンスは以下のようなjson形式(型はOpenAIObject)になっていますので、いい感じに取り出す必要があります。
```json
{
  "choices": [
    {
      "finish_reason": "stop",
      "index": 0,
      "message": {
        "content": "Qiita\u306f\u3001\u6280\u8853\u7cfb\u306e\u8a18\u4e8b\u3092\u6295\u7a3f\u3059\u308b\u3053\u3068\u304c\u3067\u304d\u308bWeb\u30b5\u30fc\u30d3\u30b9\u3067\u3059\u3002\u4e3b\u306b\u30d7\u30ed\u30b0\u30e9\u30df\u30f3\u30b0\u3084\u958b\u767a\u306b\u95a2\u3059\u308b\u60c5\u5831\u3092\u5171\u6709\u3059\u308b\u305f\u3081\u306b\u5229\u7528\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u30e6\u30fc\u30b6\u30fc\u304c\u81ea\u8eab\u306e\u77e5\u8b58\u3084\u7d4c\u9a13\u3092\u5171\u6709\u3059\u308b\u3053\u3068\u3067\u3001\u958b\u767a\u306b\u304a\u3051\u308b\u8ab2\u984c\u306e\u89e3\u6c7a\u306b\u7e4b\u304c\u308b\u60c5\u5831\u304c\u96c6\u307e\u3063\u3066\u3044\u307e\u3059\u3002\u307e\u305f\u3001\u30b3\u30fc\u30c9\u3084\u8a2d\u5b9a\u30d5\u30a1\u30a4\u30eb\u3092\u5171\u6709\u3059\u308b\u3053\u3068\u3082\u3067\u304d\u308b\u305f\u3081\u3001\u958b\u767a\u306e\u52b9\u7387\u5316\u306b\u3082\u5f79\u7acb\u3061\u307e\u3059\u3002",
        "role": "assistant"
      }
    }
  ],
  "created": 1678868364,
  "id": "chatcmpl-6uGdwSt5QM2DkyNjPiGiV4NkgPJ2T",
  "model": "gpt-3.5-turbo-0301",
  "object": "chat.completion",
  "usage": {
    "completion_tokens": 154,
    "prompt_tokens": 25,
    "total_tokens": 179
  }
}

```
(日本語がおかしなことになってますがテキトーに書いたので許して)

# 実行
ここまでで必要なものはすべて揃いました。こんな感じのファイル構成になっているはずです。
```
/
├ cogs
│　└ ask.py
├ .env　
├　 main.py
└ settings.py
```
ターミナルにて実行して、Discordにてちゃんとコマンドが打てれば成功です！

ちなみに、Botを作ったあとは常駐させたいなとなるはずです。筆者はGCP(のCompute Engine)を用いて、Ubuntu上にてSupervisorを用いてサービス化を行っています。

# 終わりに
筆者も初心者に毛が生えた程度なので、あまり具体的な説明ができていないかと思いますが、こんな感じでとりあえずはBotが作れてしまいます。簡単ですね。

チャットBotとOpenAI APIとの親和性はかなり高いと思っているので、これを元にいろいろ作ってみると楽しいかもしれません。
　　
この記事がどこかの誰かの役に立てれば幸いです。

# 参考文献
https://qiita.com/Tettu0530_Qiita/items/372c8eb7a25aabc3c0c3

https://qiita.com/HikaruR/items/e518441cbe5a9a6acd77

https://qiita.com/Luapy/items/3abff9575e132e2955ec

https://note.com/dotinkasra/n/n3b365ef33cf9

https://qiita.com/technerd/items/da9a175f50799b11d5b1

https://qiita.com/sizumita/items/c58170b72790df8ba417

https://dev.classmethod.jp/articles/openai-api-chat-python-first-step/