🖥️

【CC:Tweaked】MinecraftにてBotのデプロイに成功した話

に公開

はじめに

この記事は、Minecraft内でLua言語のプログラムを動かせるMOD(ゲーム可変プログラム)、『Computer Craft(CC: Tweaked)』を活用し、コミュニケーションツール『Discord』のBotを動作させることを目的とした記事です。

使用環境

Minecraft Forge 1.20.1
CC:Tweaked 1.113.1

結論

CC:Tweaked環境でのDiscord bot実装において、WebSocket制限は根本的な技術的制約であることが判明したため、REST APIベースの実装によりこの制約を完全に回避し安定したbot機能を実現しました。

REST APIの実装

HTTP REST API ベースの設計

-- HTTPリクエストヘルパー関数
local function discord_request(endpoint, method, data)
    local url = "https://discord.com/api/v10" .. endpoint
    local headers = {
        ["Authorization"] = "Bot " .. config.token,
        ["Content-Type"] = "application/json",
        ["User-Agent"] = "DiscordBot (CC:Tweaked, 1.0)"
    }

    local handle, err
    if method == "GET" then
        handle, err = http.get(url, headers)
    elseif method == "POST" then
        handle, err = http.post(url, options.body, headers)
    end

    if handle then
        local response_body = handle.readAll()
        handle.close()
        return json(response_body), nil
    else
        return nil, err
    end
end

ポーリングベースのメッセージ処理

-- メッセージポーリングループ
local function message_polling_loop(callback)
    while true do
        if channel_id then
            local messages = get_messages(channel_id, 5)
            if messages and #messages > 0 then
                for i = #messages, 1, -1 do
                    local message = messages[i]
                    if message.id ~= last_message_id then
                        if callback then
                            callback(message)
                        end
                        last_message_id = message.id
                    end
                end
            end
        end
        os.sleep(2)
    end
end

CC:Tweakedについて

ここからは、ComputerCraft(CC:Tweaked)について解説していきます

1.ComputerCraftとは

ComputerCraftは、dan200氏が開発したMinecraftのMODであり、Minecraftの世界に、プログラムの作成・実行が可能なコンピュータや、プログラムで動作させることができるロボットを追加するMODです。
Minecraftでは、ゲーム内に登場する「レッドストーン」というアイテムが存在し、仮想のデジタル回路を構築することができます。疑似的な導線や入力装置、出力装置を組み合わせ、論理ゲートを構築することができ、複雑な回路を構築することができますが、本MODではプログラムによってそれらを再現することができます。

Minecraftにて再現したXOR回路
ComputerCraftは、Minecraft 1.12(2017年リリースのアップデート)以降のバージョン更新を停止し、2017年にGitHubにてオープンソース化されました。その後、有志の開発者により様々な派生が作成されましたが、SquidDev氏の開発した「CC: Tweaked」を今回は採用しました。

CC:Tweakedで実装されているコンピューター

2.ディレクトリ構造

CC:Tweakedは、Javaで動作するMinecraft内でLuaを動作させるため、「Craft OS」という独自OSがゲーム内のコンピューターに組み込まれています。Craft OSのディレクトリはゲーム内のセーブファイルに保存されており、以下のような構造となっています。

minecraft/
└── [セーブデータ名]
    ├── (省略)
    └── saves
        ├── (省略)
        └── computercraft/
            ├── computer/
            |    └── [ゲーム内コンピューターの識別番号]
            |           └── [Luaの実行ファイル]
            └── ids.json

余談ですが、Luaの実行ファイルは、一応ゲーム内のコンピューターを用いて編集が可能ですが、他IDE・コードエディタと比較して機能が少ないため、VSCode等のツールでファイルを直接編集することをおすすめします。

ゲーム内コンピューターに搭載されているエディタ

実装の過程

ここからは、CC:Tweakedを用いたDiscord Bot実装の過程について記述していきます。

1.WebSocketを用いたBot構築

CC:Tweakedに標準で搭載されているhttp通信を利用しました。
なお、参考資料が少なかったため、一部にAI(Gemini 2.5Pro)を使用しています。
Discord Developer Portalの設定については、下記記事を参照しました。
https://zenn.dev/t4t5u0/articles/cd731e0293cf224cb4dc

また、プログラムの整理のためディレクトリ構成を試験的に以下のようにしています。

└── [ゲーム内コンピューターの識別番号]
        └── config.lua ー ボットトークン、チャンネル、その他設定等
        └── discord_lib.lua ー Discordとの通信を行うコア機能
        └── startup.lua ー ボットの起動

config.lua

local config = {}
config.token = "YOUR_BOT_TOKEN"

config.identity = {
  op = 2, -- オペコードの指定
  d = {
    token = config.token,
    intents = 3328, -- Discordの権限(GUILDS (1) + GUILD_MESSAGES (512) + MESSAGE_CONTENT (32768) = 33281)
    properties = {
      os = "CraftOS",
      browser = "CC:Tweaked",
      device = "Computer"
    }
  }
}

return config

discord_lib.lua

local json = textutils.unserializeJSON
local serialize = textutils.serializeJSON

local M = {}

local ws
local heartbeat_interval
local last_sequence = nil

-- ハートビートを送信し続ける関数
local function send_heartbeat()
  while true do
    os.sleep(heartbeat_interval / 1000)
    local payload = { op = 1, d = last_sequence }
    local ok, err = ws.send(serialize(payload))
    if ok then
      print("Heartbeat sent.")
    else
      printError("Heartbeat failed: " .. tostring(err))
      break
    end
  end
end

-- イベントを受信し続ける関数
local function event_listener(callback)
  while true do
    local msg, isBinary = ws.receive()
    if not msg then
      printError("Connection closed.")
      break
    end

    local data = json(msg)
    if data.s then
      last_sequence = data.s
    end

    -- Opcode 0 (Dispatch) イベントのみを処理
    if data.op == 0 then
      if data.t == "MESSAGE_CREATE" then
        callback(data.d)
      end
    end
  end
end

-- メインの接続関数
function M.connect(config, callback)
  -- 1. Gateway URLの取得
  print("Getting Gateway URL...")
  local handle, err = http.get("https://discord.com/api/v10/gateway/bot", {
    ["Authorization"] = "Bot " .. config.token
  })
  if not handle then printError("Failed to get gateway URL: " .. err); return end
  local body = handle.readAll()
  handle.close()
  local gateway_url = json(body).url .. "/?v=10&encoding=json"

  -- WebSocket接続
  print("Connecting to Gateway: " .. gateway_url)
  ws, err = http.websocket(gateway_url)
  if not ws then printError("WebSocket connection failed: " .. err); return end
  print("WebSocket Connected!")

  -- 最初に必ず届く Opcode 10 Hello を受信する
  print("Waiting for Hello from Discord...")
  local first_msg = ws.receive()
  if not first_msg then printError("Connection failed before Hello"); ws.close(); return end

  local hello_data = json(first_msg)
  if hello_data.op ~= 10 then
    printError("Expected Opcode 10 (Hello), but got " .. tostring(hello_data.op))
    ws.close()
    return
  end
  print("Received Hello from Discord.")
  heartbeat_interval = hello_data.d.heartbeat_interval

  -- Helloを受信後、速やかにIdentifyペイロードを送信する
  print("Sending Identify payload...")
  ws.send(serialize(config.identity))

  -- これで準備完了。ハートビートとイベントリスナーを並行で開始する
  print("Bot Identified. Starting main loops...")
  parallel.waitForAny(
    function() send_heartbeat() end,
    function() event_listener(callback) end
  )

  -- どちらかの処理が終わったら(=接続が切れたら)接続を閉じる
  ws.close()
end

return M

startup.lua

local config = require("config")
local discord = require("discord_lib")

term.clear()
term.setCursorPos(1, 1)
print("Discord Bot Initializing...")

-- Discordからメッセージが届いた時に実行する処理を定義
local function on_message_received(message)
  local author = message.author.username
  local content = message.content
  print(author .. ": " .. content)
end

-- ライブラリのconnect関数を呼び出してボットを開始
-- on_message_received関数を渡して、メッセージ受信時に実行されるようにする
discord.connect(config, on_message_received)

print("Bot has been disconnected.")

実装内容

本実装によって、DiscordBotをアクティブにし、チャットしたユーザー名とそのユーザーのチャット内容の読み取りに成功

課題

ボットは正常に動作するが、約40秒程度経過すると必ず接続が切断される

2.ハートビート(死活監視)の対応

上記コードの原因として、Discord側が要求するハートビート(死活管理)の対応に失敗することが挙げられました。本問題の原因と思われる点に以下が挙げられました。

  1. 処理の遅延によって要求するハートビートの送信タイミングに信号を送れない可能性
  2. CC:Tweakedのparallel関数のバグ
  3. CC:Tweakedのコンピューターに割り当てられるメモリが不足している
  4. CC:TweakedのWebSocket通信に制限がある

2.1.待機方式からタイマー方式に変更

os.sleep()関数を使用し待機後にハートビートを送信する方式では、処理による遅延によってDiscordが期待するタイミングからわずかに遅れてしまっているのが原因と考えられました。
これを解決するため、より安定的で信頼性の高いタイマー方式にプログラム全体を書き直しました。

-- discord_lib.lua

local json = textutils.unserializeJSON
local serialize = textutils.serializeJSON

local M = {}

local ws
local heartbeat_timer = nil
local heartbeat_interval
local last_sequence = nil
local received_heartbeat_ack = true

local function send_heartbeat()
    if not received_heartbeat_ack then
        printError("Heartbeat ACK not received. Connection may be unstable.")
        ws.close()
        return false
    end

    received_heartbeat_ack = false
    local payload = { op = 1, d = last_sequence }
    local ok, err = ws.send(serialize(payload))

    if ok then
        print("Heartbeat sent.")
    else
        printError("Heartbeat failed to send: " .. tostring(err))
        return false
    end
    return true
end

function M.connect(config, callback)
    print("Getting Gateway URL...")
    local handle, err = http.get("https://discord.com/api/v10/gateway/bot", {
        ["Authorization"] = "Bot " .. config.token
    })
    if not handle then printError("Failed to get gateway URL: " .. err); return end
    local body = handle.readAll()
    handle.close()
    local gateway_url = json(body).url .. "/?v=10&encoding=json"

    print("Connecting to Gateway: " .. gateway_url)
    ws, err = http.websocket(gateway_url)
    if not ws then printError("WebSocket connection failed: " .. err); return end
    print("WebSocket Connected!")

    while true do

        local event, p1, p2 = os.pullEvent()
        print("DEBUG: Event pulled:", event)

        if event == "websocket_message" then
            if p1 ~= gateway_url then goto continue end
            local data = json(p2)
            if data.s then last_sequence = data.s end

            local opcode = data.op
            if opcode == 10 then
                print("Received Hello from Discord.")
                heartbeat_interval = data.d.heartbeat_interval
                print("Sending Identify payload...")
                ws.send(serialize(config.identity))
                local first_delay = heartbeat_interval * math.random() / 1000
                heartbeat_timer = os.startTimer(first_delay)
                print("Bot Identified. First heartbeat in " .. string.format("%.2f", first_delay) .. "s.")

            elseif opcode == 11 then
                received_heartbeat_ack = true
            elseif opcode == 0 then
                if data.t == "MESSAGE_CREATE" then
                    callback(data.d)
                end
            end

        elseif event == "timer" then
            if p1 == heartbeat_timer then
                if send_heartbeat() then
                    heartbeat_timer = os.startTimer(heartbeat_interval / 1000)
                else
                    break
                end
            end

        elseif event == "websocket_closed" then
            printError("WebSocket connection was closed.")
            break
        end
        ::continue::
    end

    if heartbeat_timer then os.cancelTimer(heartbeat_timer) end
    if ws then ws.close() end
    print("Bot has been disconnected.")
end

return M

結果

**ハートビートに失敗。**40秒程度で切断した。

原因

原因が不明だったので、AI(Gemini 2.5Pro)を使用して解読しました。

これは非常に奇妙で、解決が難しい問題です。

ログを詳しく見ると、以下のことがわかります。

ボットは正常に認証され、Discordもそれを受け入れています。

「30.74秒後に最初のハートビートを送ります」というタイマーの予約にも成功しています。

しかし、実際にタイマーが作動してハートビートを送ろうとした瞬間には、すでに接続が切れてしまっています。(Heartbeat failed to send: nil は、ws.send を試みたが接続が存在しなかった、という意味です)

つまり、タイマーが作動するまでの30秒間の待機時間中に、何らかの原因で接続が「静かに」切断されていると考えられます。本来であればwebsocket_closedというイベントが記録されるはずですが、それも表示されていません。

これは、前回導入した「os.pullEventを使った単一のイベントループ」という仕組みが、ComputerCraftの環境と稀な相性問題を起こし、WebSocket接続を意図せず不安定にさせている可能性を示唆しています。

以上から、os.pullEvent方式を廃止し、parallel.waitForAny方式に修正を試みました。

2.2.parallel関数への修正

-- discord_lib.lua

local json = textutils.unserializeJSON
local serialize = textutils.serializeJSON

local M = {}

local ws
local heartbeat_interval
local last_sequence = nil

local function heartbeater()
    local first_delay = heartbeat_interval * math.random() / 1000
    os.sleep(first_delay)
    local payload = { op = 1, d = last_sequence }
    local ok, err = ws.send(serialize(payload))
    if not ok then
        printError("Failed to send first heartbeat: " .. tostring(err))
        return
    end
    print(" First heartbeat sent.")
    while true do
        os.sleep(heartbeat_interval / 1000)
        payload = { op = 1, d = last_sequence }
        ok, err = ws.send(serialize(payload))
        if ok then
            print("Heartbeat sent.")
        else
            printError("Failed to send subsequent heartbeat: " .. tostring(err))
            break
        end
    end
end

local function event_listener(callback)
    while true do
        local msg = ws.receive()
        if not msg then
            printError("Connection lost in event listener.")
            break
        end

        local data = json(msg)
        print("DEBUG: Received Opcode " .. tostring(data.op) .. ", Event Type: " .. tostring(data.t))

        if data.s then
            last_sequence = data.s
        end
        if data.op == 0 and data.t == "MESSAGE_CREATE" then
            callback(data.d)
        end
    end
end

function M.connect(config, callback)
    print("Getting Gateway URL...")
    local handle, err = http.get("https://discord.com/api/v10/gateway/bot", {
        ["Authorization"] = "Bot " .. config.token
    })
    if not handle then printError("Failed to get gateway URL: " .. err); return end
    local gateway_url = json(handle.readAll()).url .. "/?v=10&encoding=json"
    handle.close()

    print("Connecting to Gateway: " .. gateway_url)
    ws, err = http.websocket(gateway_url)
    if not ws then printError("WebSocket connection failed: " .. err); return end
    print("WebSocket Connected!")

    local hello_msg = ws.receive()
    if not hello_msg then printError("Did not receive Hello from Discord."); ws.close(); return end
    local hello_data = json(hello_msg)
    heartbeat_interval = hello_data.d.heartbeat_interval
    print("Received Hello. Heartbeat interval: " .. heartbeat_interval .. "ms")

    print("Sending Identify payload...")
    ws.send(serialize(config.identity))

    print("Bot Identified. Starting parallel loops...")
    parallel.waitForAny(
        function() heartbeater() end,
        function() event_listener(callback) end
    )

    if ws then ws.close() end
    print("Bot has been disconnected.")
end

return M

結果

失敗。ボットが認証を終えた直後(10秒〜30秒)に、ログなしで接続が切れるようになった。

原因

原因が不明だったので、AI(Gemini 2.5Pro)を使用して解読しました。

度重なる試行、誠にありがとうございます。そして、問題が解決せず大変申し訳ございません。

ログを拝見し、ユーザー様からの「10秒程度で切れる」というご報告と組み合わせた結果、これまでの仮説がすべて覆る、たった一つの根本原因にたどり着くことができました。

真の根本原因:コンピューターの性能限界
問題は、プログラムのコードではなく、ボットを動かしているコンピューターそのものにあります。

このエラーは、ほぼ間違いなく普通のアドバンスドではないコンピューター (Normal Computer) でプログラムを実行しているために発生しています。

何が起きているか:

ボットがDiscordに接続すると、Discordは「サーバーの全情報(READYイベント)」という巨大な初期データを送り返します。

普通のコンピューターのメモリ(RAM)は512KBしかありません。

この巨大なデータをws.receive()で受信しようとした瞬間、512KBのメモリがすべて使い果たされ、コンピューター自体がクラッシュしています。

これが、エラーログも出さずにプログラムが突然停止し、接続が「静かに」切れているように見えた真の理由です。メモリ不足でOSごと落ちてしまっているため、デバッグログを表示する余裕すらないのです。

以上から、CC:Tweakedの個別のコンピューターに割り当てられるメモリー数を、config設定から変更することにしました。

2.3.CC:Tweakedのコンピューターの割り当てメモリ数を増加

CC:Tweakedのconfig設定ファイルは以下のディレクトリにあります。

minecraft/
└── [セーブデータ名]
    ├── (省略)
    └── saves
        ├── (省略)
        └── serverconfig/
            ├── forge-server.toml
            └── computercraft-server.toml ー 今回変更するファイル

computercraft-server.tomlファイルでは、コンピューター一台に割り当てられるストレージ量やHTTP APIの制限、無線通信の範囲などを設定できます。
以下の記事を参照して設定の変更を試みました。
https://qiita.com/sonoisa/items/75b6d0181bbbb21873c2
ファイル内にメモリー数を割り当てる項目がありませんでした。
<補足>
CC:Tweakedにはメモリー数の上限が存在しません。
CC:TweakedのコンピュータはMinecraftサーバーまたはクライアントを実行する基盤となるJava仮想マシン(JVM)が提供する、動的で共有されたメモリプール内で動作します。
Geminiの回答の中でコンピュータ、及びアドバンスドコンピューターのメモリ割り当て数についての言及がありましたが、これらはすべてハルシネーションと考えられます。

2.4.WebSocket通信以外の方法を模索

コンフィグの参照により、CC:TweakedのHTTP APIには制約があることがわかりました。
そのため、WebSocket通信を利用したDiscord通信には問題がある可能性があります。

確認のため、以下のテストを実施しました。
<Identify ペイロード送信のテスト>

-- 送信失敗
local ok, err = ws.send(identify_payload)
-- 結果: ok = nil, err = nil

<WebSocket 送信のテスト>

-- テスト送信も失敗
local test_ok = ws.send('{"test":true}')
-- 結果: test_ok = nil (送信機能が完全に無効化)

これらのテストにより、以下の原因が判明しました。

  1. 送信機能の喪失: Hello メッセージ受信後、WebSocket が読み取り専用状態になる
  2. Discord Gateway との互換性問題: CC:Tweaked の WebSocket 実装が Discord Gateway の仕様と完全に互換していない

以上から、WebSocket通信に制限があるため、回避のためにDisocord REST APIを使用した実装に変更しました。

-- HTTPリクエストヘルパー関数
local function discord_request(endpoint, method, data)
    local url = "https://discord.com/api/v10" .. endpoint
    local headers = {
        ["Authorization"] = "Bot " .. config.token,
        ["Content-Type"] = "application/json",
        ["User-Agent"] = "DiscordBot (CC:Tweaked, 1.0)"
    }

    local handle, err
    if method == "GET" then
        handle, err = http.get(url, headers)
    elseif method == "POST" then
        handle, err = http.post(url, options.body, headers)
    end

    if handle then
        local response_body = handle.readAll()
        handle.close()
        return json(response_body), nil
    else
        return nil, err
    end
end
-- メッセージポーリングループ
local function message_polling_loop(callback)
    while true do
        if channel_id then
            local messages = get_messages(channel_id, 5)
            if messages and #messages > 0 then
                for i = #messages, 1, -1 do
                    local message = messages[i]
                    if message.id ~= last_message_id then
                        if callback then
                            callback(message)
                        end
                        last_message_id = message.id
                    end
                end
            end
        end
        os.sleep(2) -- 2秒間隔でポーリング
    end
end

終わりに

CC:Tweaked環境でのDiscord bot実装において、WebSocketにはサーバーの安定化のために、config設定にて一定の制限がされていることが判明しました。
REST APIベースの実装により、この制約を完全に回避し、安定したbot機能を実現することができます。
今回の実装では、Discordのログインとテキストの読み取りという簡易的で一方的な通信を実装しましたが、今後はスラッシュコマンドの実行等のDiscord botの様々な機能を備えたライブラリの構築を目指していきます。

関連項目

GitHubレポジトリ: https://github.com/HikariTakahashi/MineDisco

Discussion