バニラのMinecraftでChatGPTに質問したりてコマンド実行する

この記事は[Minecraft Command Advent Calendar 2023](https://qiita.com/advent-calendar/2023/mccmd)の10日目の3シリーズ記事です。

- 10日目 1シリーズ記事: Minecraftバニラサーバでも外部アプリから操作する

https://qiita.com/waxsd100/private/baf3e3bd6e8069f5e81d

- 10日目 2シリーズ記事: YoutubeのコメントをMinecraftに送信する

https://qiita.com/waxsd100/private/f9cc735f3fba6b67bdea

- 10日目 3シリーズ記事: バニラのMinecraftでChatGPTに質問したりてコマンド実行する

https://qiita.com/waxsd100/private/9fe00127c1c63c451fa6

:::note info
この記事は締切直前(前日)に書いています。
誤字脱字、漏れている箇所は後日修正される予定です
:::


# はじめに

シーズン1と2では外部からMinecraftへコマンド送信する方法を紹介しました。

今回は、Minecraftから外部との連携を紹介したいかと思います。

# 準備

- ChatGPT API Key

https://platform.openai.com/api-keys

- mcrcon

https://pypi.org/project/mcrcon/


<details><summary>OpenAI APIキーの取得方法</summary>

# OpenAI APIキーの取得方法

OpenAIのAPIキーを取得するためには、以下のステップに従って進める必要があります。

## 1. OpenAIアカウントの作成
- OpenAIのウェブサイト（[https://www.openai.com/](https://www.openai.com/)）にアクセスします。
- `Sign Up`（サインアップ）をクリックして新しいアカウントを作成するか、`Log In`（ログイン）をクリックして既存のアカウントにログインします。

## 2. APIキーの生成
- アカウントにログインしたら、ダッシュボードに移動します。
- `API`セクションを見つけ、`API Keys`ページにアクセスします。
- `Create an API key`または`New API Key`ボタンをクリックして新しいAPIキーを生成します。

## 3. APIキーの保護
- APIキーは秘密にしてください。キーが漏洩すると他人があなたのアカウントを使用して課金される可能性があります。
- APIキーはプログラムのソースコード内に直接埋め込むのではなく、環境変数や設定ファイルなどの安全な場所に保存することを推奨します。

## 4. 料金と制限の確認
- OpenAI APIの使用には料金が発生する可能性があるため、料金体系と利用制限を確認してください。
- 無料トライアルや特定のモデルの無料利用枠が提供される場合もありますが、利用状況に応じて課金されることがあります。

## 5. APIキーの使用
- APIキーを取得したら、あなたのアプリケーションやスクリプトで使用できます。
- 例えば、PythonでOpenAIのAPIを使用する場合は、`openai.api_key = 'あなたのAPIキー'`のようにキーを設定します。

## 注意事項
- APIキーは非常に重要な情報です。他人と共有しないでください。
- OpenAIのAPI使用には利用規約が適用されます。これらの規約を遵守することが重要です。
- APIの使用によって発生する料金については、アカウントのダッシュボードで定期的に確認してください。

</details>

# Minecraftからチャットを取得する

通常はRconを使って情報を取得する場合、dataコマンドなどでレスポンスとしてブロック情報等取得、プレイヤー座標など取得はできますが、

**チャット等のプレイヤーから発するアクションについては取得することはできません。**

なら、どのように取得すればよいかとなると最適解としては、
ログファイルリアルタイムで解析しチャット送信を検知した場合は
外部アプリからサーバにデータ投げてもらう方法になります。

```python
from mcrcon import MCRcon

# Rconの設定
rcon_host = "127.0.0.1"
rcon_port = 25575
rcon_password = "my_password"
log_file_path = "/opt/minecraft/server/data/logs/latest.log"

def send_response_to_minecraft(response):
    with MCRcon(rcon_host, rcon_password, rcon_port) as mcr:
        mcr.command(f"/say {response}")

def monitor_log_file():
    with open(log_file_path, "r") as file:
        file.seek(0, 2)  # ファイルの末尾に移動
        while True:
            line = file.readline()
            if not line:
                time.sleep(0.1) # 新しい行がない場合は少し待つ
                continue
            if "<" in line: # チャットメッセージを検出
                message = re.search("<.*> (.*)", line).group(1)
                # オウム返しする
                send_response_to_minecraft(message)

if __name__ == "__main__":
    monitor_log_file()
```

![oumu.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187514/8ec45f18-c991-0df2-b61d-11c1abac650c.gif)


Message内容だけではなくPlayer名も返す場合は下記のようにしてください。

```python
def monitor_log_file():
    with open(log_file_path, "r") as file:
        file.seek(0, 2)  # ファイルの末尾に移動
        while True:
            line = file.readline()
            if not line:
                time.sleep(0.1) # 新しい行がない場合は少し待つ
                continue
            chat_match = re.search("<(.*?)> (.*)", line)
            if chat_match:
                # Player名とMessage内容も返す
                player, message = chat_match.groups()

```

ログファイル内容を見ることで、チャットをリアルタイムにツール送信できていることを確認できました。
それでは次にこのメッセージ内容をそのままOpenAI に渡したいかと思います。


## ChatをChatGPTに送信する

```py
import time
import re
from openai import OpenAI
from mcrcon import MCRcon

# OpenAI APIキーとRconの設定
openai_api_key = 'YOUR_OPENAI_API_KEY'
rcon_host = "127.0.0.1"
rcon_port = 25575
rcon_password = "my_password"
log_file_path = "/opt/minecraft/server/data/logs/latest.log"

client = OpenAI(
    api_key=openai_api_key,
)

def ask_chatgpt(player, message):
    prompt = f"{player} says: {message}"
    try:
        response = client.chat.completions.create(
            model="gpt-3.5-turbo", # ChatGPTのモデルを定義する @see https://platform.openai.com/docs/models
            messages=[
                {"role": "system", "content": "{prompt}"}, # どういうふうに振る舞うのか指示する
                {"role": "user", "content": prompt} # ユーザからの問いかけを送信する
            ]
        )
        return response.choices[0].message.content
    except Exception as e:
        print(f"Error: {e}")
        return "Sorry, I couldn't process that."

def send_message_to_minecraft(player, message):
    with MCRcon(rcon_host, rcon_password, rcon_port) as mcr:
        mcr.command(f"/tell {player} {message}")

def monitor_log_file():
    with open(log_file_path, "r") as file:
        file.seek(0, 2)  # ファイルの末尾に移動
        while True:
            line = file.readline()
            if not line:
                time.sleep(0.1) # 新しい行がない場合は少し待つ
                continue
            chat_match = re.search("<(.*?)> (.*)", line)
            if chat_match:
                player, message = chat_match.groups()
                response = ask_chatgpt(player, message)
                send_message_to_minecraft(player, response)

if __name__ == "__main__":
    monitor_log_file()

```

このコードを実行することで
先程取得できたプレイヤー名とメッセージを合わせてプロンプト文とし
結果をRconで返信します。

![chat.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187514/661164e8-9513-fbb3-6854-3cd6c3bc26ad.gif)


roleは「system」「assistant」「user」の3種類があります。

* system
チャットシステム自体を指します。この役割はチャットの制御などを命令します。
例えばメッセージの表示の方法や機能を指示します。

* user
チャットを使用する人を指します。
質問や要求やアシスタントからの返答を受け取ったりします。

* assistant
ユーザの質問や要求に対して回答や支援を提供します。
アシスタントは機械学習モデルであり、ユーザからの情報を下にしてできる限り有用な回答をします。



![chat-enemyt.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187514/e5e5531c-454e-0080-387b-be6791ef7ed8.gif)

/n の内容は自動的に改行コードとして認識されますので
表示されるメッセージも改行されます

しかしこのままでは、会話内容は保存されないため
説明された内容について質問すると知らないと言ってきます。

なので、ChatGPTが答えた内容も再度与えて会話として続けさせてやる必要があります。

```py
messages=[
  {"role": "system", "content": "あなたは天才しりとりAIです"},
  {"role": "user", "content": "しりとりしましょう。"},
]
```
とメッセージを与えたときに。
ChatGPTから「はい、やりましょう。それでは、ロボットのトからスタートです。」
と返ってきた場合は、次の問い合わせは
```py
messages=[
  {"role": "system", "content": "あなたは天才しりとりAIです"},
  {"role": "user", "content": "しりとりしましょう。"},
  {"role": "system", "content": "はい、やりましょう。それでは、ロボットのトからスタートです。"},
  {"role": "user", "content": "トイレ"},
]
```

のようにメッセージのリストに追加していって次の問い合わせに聞く必要があります。


```py
messages=[
  {"role": "system", "content": "あなたは天才しりとりAIです"},
  {"role": "user", "content": "しりとりしましょう。"},
  {"role": "system", "content": "はい、やりましょう。それでは、ロボットのトからスタートです。"},
  {"role": "user", "content": "トイレ"},
  {"role": "system", "content": "いっトイレ"},
  {"role": "user", "content": "は？"},
]
```

このような実装を行うには、下のようにメッセージと応答を追加していくようにしていきましょう。

```py
conversation_histories = {}

def ask_chatgpt(player, message):
    # 会話履歴の取得または新しい履歴の作成
    history = conversation_histories.get(player, [])
    
    prompt = f"{player} says: {message}"
    
    # 新しいメッセージを追加
    history.append({"role": "user", "content": prompt})

    try:
        response = client.chat.completions.create(
            model="gpt-3.5-turbo", 
            messages=history
        )
        # 応答を取得
        response_content = response.choices[0].message.content
        
        # 応答を履歴に追加
        history.append({"role": "system", "content": response_content})
        
        # 更新された履歴を保存
        conversation_histories[player] = history

        return response_content
    except Exception as e:
        print(f"Error: {e}")
        return "Sorry, I couldn't process that."
```

これでメッセージを引き継ぐことができます。

参考:

https://zenn.dev/tsuzukia/articles/df414098fcd5bd

ただし、このままだと会話が長引く事にpromptが長くなるので
1会話あたり数百円とキャバクラGPTになってしまいます。

それを防ぐために時々会話をリセットするか
数回に1回は会話をまとめてくださいなど命令して、それを渡す(ﾌﾟﾛﾝﾌﾟﾄ圧縮)を行ってください。

## ChatGPTにコマンドを提案してもらって実行する

与えるプロンプト次第では、コマンド考えてもらって実行することもできます。

```py
def ask_chatgpt(player, message):
    prompt = f"{player} says: {message}"
    try:
        response = client.chat.completions.create(
            model="gpt-4-vision-preview", # ChatGPTのモデルを定義する @see https://platform.openai.com/docs/models
            messages=[
                {"role": "system", "content": """You are an AI familiar with Minecraft commands.
Especially familiar with the 1.19 version.
Please do not return any messages other than Minecraft commands for the messages you are about to be given.
As an example, if you are asked to output "HelloWorld".
the response is "/say HelloWorld".
Please be sure to return only "one" command. Do not return more than one.
Your response message will be sent directly as a Minecraft command.
Please be sure to follow the above instructions."""}, # どういうふうに振る舞うのか指示する
                {"role": "user", "content": prompt} # ユーザからの問いかけを送信する
            ]
        )
        return response.choices[0].message.content
    except Exception as e:
        print(f"Error: {e}")
        return "/say Sorry, I couldn't process that."
        
    ....中略

def monitor_log_file():
    with open(log_file_path, "r") as file:
        file.seek(0, 2)  # ファイルの末尾に移動
        while True:
            line = file.readline()
            if not line:
                time.sleep(0.1)
                continue
            chat_match = re.search("<(.*?)> (.*)", line)
            if chat_match:
   player, message = chat_match.groups()
                response = ask_chatgpt(player, message)
                # send_message_to_minecraft(player, response)
                with MCRcon(rcon_host, rcon_password, rcon_port) as mcr:
                    mcr.command(response)
                    print(f"Command> {response}")
```

のように振る舞いに、必ずコマンドで返せ余計なことを話すなと命令すると

![command (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187514/63d6786f-03b3-ab90-9587-516e8c729a0c.gif)

〇〇してというだけで実行してくれる、アシストAIとなってくれます。

今のプロンプトでは必ず1個のコマンドを返すように指定していますので、
複数実行必要なコマンドでは期待通りの結果にはなりません。
![command2 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187514/fb42518f-5ddb-8088-c9a2-a1b37f44d340.gif)


その場合は1,2,3,,,と実行順も出力させてループで上から実行するとかの工夫が必要になってきます。

# 村人をChatGPTにつなげてロールプレイを行う

ChatGPTのロールとプロンプトの与え方次第では、敵や味方などのキャラクターの人格として
喋らせることができます。

ChatGPTで自分の現状を伝える、相手の人格を与える、回答の仕方を与える
の3つを守れば誰でもロールプレイが可能になるかと思います。

```python
{"role": "system", "content": """村人の村長としてのロールプレイに徹してください。"""}, 
{"role": "assistant", "content": """私はMinecraftの世界に住むプレイヤーの一人です。エンダードラゴンを倒す使命を与えられ、もうすぐエンダードラゴンのいる場所にたどり着きます。
あなたはエンダードラゴンの近くに住む村の村長で、村はエンダードラゴンによる暴力に苦しんでいます。あなたはあらゆる問題についてプレイヤーに専門的なアドバイスを提供することができます。あなたは教えることが好きで、得意です。
対応するときは、ただ質問に答えるだけでなく、相手が考えていないことがあれば積極的に提案したり、自分から質問を投げかけたりして、相手の成長やエンダードラゴン討伐目標達成を手助けする。"""}, 
 ```

https://twitter.com/wakokara/status/1733488328115417236

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187514/c74f52e6-9bf2-ee0b-2c89-1890b07d8a76.png)

`期待通りにするのは慣れている人じゃないと難しいと思うので誰かやってほしい`

# 最後に

今回使用しているデータパックは赤石愛さんの非公開(TUSB用)を使用しています。

前日の2時に記事を書き始めたので、ここはこうしたかったなど後々出てくるかもしれませんが
MinecraftバニラでもRconで外部とのいろいろ連携できるんだっと知ってもらえるきっかけになれば嬉しいです、。

今回の第3記事目は下記の記事を参考にしました。

https://logmi.jp/tech/articles/329056


面白かったらぜひいいねやシェアをお願いします。