手抜きなbotを開発し(てもらっ)た

初投稿です。色々誤字脱字見づらいかも知れません。スミマセン

前置きかも

どうもです。記事を書いてる日から1年ぐらい前にDiscordにMinecraftサーバーの死活監視(動いてるか死んでるか監視すること)をするbotを作ってました。その時はPythonだったかな
それでそのbotがしばらくして役目を終えたんですよね、一時的なものだったから。それで、ちょっと前に整理としてDiscordのBotを動かすことを専門とするサーバーからデータ整理のために消したんですよね。
そしたらちょうどその2週間後ぐらいに久しぶりにもっかいやることになったんですよね。まぁなんともタイミングの悪い。せっかくなので今回はGASで動かしてみました~
ほぼClaude任せですが意外とトラブルシューティングに苦労したのでどうぞ読んでってくださいね
あと監視先のサーバーはJava Editionのサーバーなので、Bedrock Editionはちょっと変わります。書いてませんが、ソースコードをちょっといじればおっけーです。


必要なもの

やってて必要だったものはこんな感じですね。

  • Discordアカウント

  • Googleアカウント

  • 報告する先のDiscordサーバーの「ウェブフックの管理」権限

  • Cloudflareアカウント(Googleアカウントあればそのまま進めるので特に必要ないかも)

Discordアカウント(&権限)

報告するためですしそのままやり取りするなら当然ですねー
なかったら話が始まりません。
ウェブフック(Webhook)について軽く紹介します。知ってる人は読み飛ばしてね。といっても適当です
Webhookはサービスとサービスを簡易的につなぐすごーいやつです。通信は一方的ですが、この目的には十分です。
この場合のWebhookは、Discordのチャンネルにメッセージを送りつけるために使います。
なお漏洩すると無関係な第三者が爆撃できるようになるので管理には十分ご注意ください。

Googleアカウント(&Cloudflareアカウント)

今回はGoogle Apps ScriptというGoogleの提供するサービスを使って作成します。本来はGoogleだけで完了するところですが、ちょっと使っててCloudflareも必要だなーって思ったので追加しました。

いざ作ってみよう

というわけでやっていきましょう。書いてくれるのはClaudeですけどね。
いくつか方向性を決めてレッツゴー

方向性(計画)

今回は、
Minecraftのサーバーの状態を把握するAPI→GAS→(Cloudflare)→Discord
という経路で回していきます。

このAPIですが、私が把握している限りでは2つあって、
https://mcsrvstat.us/ と https://mcstatus.io/ の2つです。今回は後者を利用します。サーバーのアドレス(のトンネルサービス)との相性なのか、なぜか前者では常にオフラインと返ってくるので後者を使っていきます。

GASでは、APIを叩いて解析、Discordへの送信をします。コードは単純じゃないですが、やってることは単純です。

Cloudflareでは、GASからのWebhookをDiscordに中継させる事だけをしてます。色々調べた感じだと、GASからDiscordに通信する過程で、GASからDiscordに送信する他のユーザー全員が同じ経路を使用していて、エラー内容的にもそのIPに目をつけてDiscord側から拒否されてしまったので、Cloudflareを中継させて送信するようにしています。

Cloudflareの実装

まずログインをしてダッシュボードを開きましょう。

今回が初めて触ることになる人はちょっと先まで飛ばしてください。

Cloudflareでなにか既に作っている人は、「Workers & Pages」を開いて

ダッシュボードの左側

「Create application」を押して新しく作りましょう。

画面右上かな

使ったことない人はここで合流です!
Cloudflareから「どこのソースから作る?って聞かれるので」とりあえず「Start with Hello World!」を選んでおきましょう。

緑の丸いやつ
Spotifyみたいだね

そしたらWorker nameは自分が覚えやすいものを指定して、右下のボタンでデプロイしましょう。

英語でも今の時代を生きる子たちなら余裕だよね!
私はそう信じてるわ
あとURLは例です
叩いても意味はありません

移動したページの上部の「Settings」から、「Variables and secrets」の右側にある「Add」から変数を追加します。

わざわざ画像貼り付けるなんて私は親切だね
ちなみにここに入力した内容はその後見れなくなるので忘れないようにね

TypeをSecrectにして、2つの値を設定します。
名前をDISCORD_WEBHOOK_URL、値をDiscordのWebhookのURLにしてください。使い方には気をつけてね。
2つ目は、名前をAUTH_KEY、値は自分で決めた合言葉にしてください。このあと見れないので覚えてください、確実に。
そしたらデプロイしましょう。
その後、Edit codeからコードの編集をしましょう。

スクショ撮るのめんどくさくなってきた

そしたら左側には見たことある人は見たことあるエディターが開かれて、コードが表示されます。

VS Code…をちょっと改変したやつかな?シンプルになってるね

今表示されてるコードを全部消して、このコードを貼り付けます。

/**
 * ===========================================================
 * Discord Webhook 転送用 Cloudflare Worker
 * ===========================================================
 *
 * 役割:
 *   GAS (Google Apps Script) -> このWorker -> Discord Webhook
 *   という経路にすることで、GASの共有IPレンジがCloudflareに
 *   レート制限(429 / error 1015)される問題を回避する。
 *
 * 【デプロイ手順】
 * 1. Cloudflareダッシュボード → 「Workers & Pages」→「Create」→
 *    「Create Worker」で新規Workerを作成する
 * 2. エディタにこのコードを丸ごと貼り付けて保存 (Deploy)
 * 3. Worker の「Settings」→「Variables」で、以下の2つを
 *    “Secret” として追加する(コードには書かない)
 *
 *      DISCORD_WEBHOOK_URL : 実際のDiscord Webhook URL
 *      AUTH_KEY            : 自分で決めた合言葉(ランダムな文字列)
 *                            例: openssl rand -hex 16 などで生成
 *
 * 4. デプロイ後に発行される
 *      https://<worker名>.<あなたのsubdomain>.workers.dev
 *    というURLを、GAS側の DISCORD_WEBHOOK_URL (スクリプトプロパティ)
 *    に設定する。GAS側にも同じ AUTH_KEY をスクリプトプロパティとして
 *    設定しておくこと。
 * ===========================================================
 */

export default {
  async fetch(request, env) {
    // POST以外は受け付けない
    if (request.method !== 'POST') {
      return new Response('Method Not Allowed', { status: 405 });
    }

    // 簡易認証: GASから送られてくるヘッダーの合言葉を確認する
    // (誰でも叩けるURLになってしまうため、これが無いと第三者に
    //  勝手にメッセージを送信されてしまう)
    const providedKey = request.headers.get('X-Auth-Key');
    if (!env.AUTH_KEY || providedKey !== env.AUTH_KEY) {
      return new Response('Unauthorized', { status: 401 });
    }

    if (!env.DISCORD_WEBHOOK_URL) {
      return new Response('Worker misconfigured: DISCORD_WEBHOOK_URL is not set', {
        status: 500,
      });
    }

    let discordResponse;
    try {
      const payload = await request.text();
      discordResponse = await fetch(env.DISCORD_WEBHOOK_URL, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: payload,
      });
    } catch (err) {
      return new Response('Failed to forward request to Discord: ' + err.message, {
        status: 502,
      });
    }

    // Discordからのレスポンスをそのまま返す(ステータスコードも含む)
    const body = await discordResponse.text();
    return new Response(body, {
      status: discordResponse.status,
      headers: { 'Content-Type': 'application/json' },
    });
  },
};

コメントで書いてありますが、一応セキュリティには配慮した作りになってます。アレンジはどうぞご自由に。そしたら、右上のデプロイをしましょう。デプロイができたら左上の戻るボタンみたいな雰囲気のやつを押して戻りましょう。
右上の「Visit」を右クリックして、リンクをコピーしましょう。後々使います。あ、AUTH_KEYもね。そしたらCloudflareでの操作はおしまいです。

今度は右側だよ

GASの実装

というわけで以下がGASのコードです。プロジェクトの作り方などはご自身で調べてくださいな。

/**
 * ===========================================================
 * Minecraftサーバー死活監視 → Discord通知 (GAS)
 * ===========================================================
 *
 * ※ DiscordへはCloudflare Worker経由で送信する構成になっています
 *    (GAS→Discord直POSTだと、共有IPレンジがCloudflareにレート制限
 *    される 429/error1015 問題が起きるため)
 *    Worker側のコード: discord_webhook_proxy_worker.js を参照
 *
 * 【事前準備】
 * 1. 先に discord_webhook_proxy_worker.js をCloudflare Workersに
 *    デプロイし、WorkerのURLと、自分で決めたAUTH_KEYを控えておく
 * 2. このスクリプトをGoogle Apps Scriptのプロジェクトに貼り付ける
 * 3. 左メニューの「プロジェクトの設定」→「スクリプト プロパティ」で
 *    以下の3つを追加する(コードに直接書かないことで、URLやアドレスを
 *    人に見せずに済みます)
 *
 *      SERVER_ADDRESS        : 監視したいMinecraftサーバーのアドレス
 *                               例) mc.example.com  や  123.45.67.89:25565
 *      DISCORD_WEBHOOK_URL   : Cloudflare WorkerのURL
 *                               例) https://xxx.yyy.workers.dev
 *      WORKER_AUTH_KEY        : Worker側に設定したAUTH_KEYと同じ値
 *
 * 4. スクリプトエディタ上部で関数 `setupTrigger` を選択して実行する
 *    → これで「毎分実行」のトリガーが1つ作成されます(1回だけ実行すればOK)
 *
 * 5. 動作確認したい場合は `forceSendTest` を手動実行してみてください
 * ===========================================================
 */

// ---- 設定値まわり ----------------------------------------------------

const ALIVE_NOTIFY_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2時間ごとの生存確認

/**
 * スクリプトプロパティから設定値を取得する
 */
function getConfig_() {
  const props = PropertiesService.getScriptProperties();
  const serverAddress = props.getProperty('SERVER_ADDRESS');
  const webhookUrl = props.getProperty('DISCORD_WEBHOOK_URL'); // Cloudflare WorkerのURLを設定する
  const authKey = props.getProperty('WORKER_AUTH_KEY'); // Worker側のAUTH_KEYと同じ値

  if (!serverAddress || !webhookUrl || !authKey) {
    throw new Error(
      'スクリプトプロパティに SERVER_ADDRESS / DISCORD_WEBHOOK_URL / WORKER_AUTH_KEY を設定してください。' +
      '(プロジェクトの設定 > スクリプト プロパティ)'
    );
  }
  return { serverAddress, webhookUrl, authKey };
}

// ---- トリガー設定 ------------------------------------------------------

/**
 * 「毎分実行」のトリガーを作成する。最初に1回だけ手動実行すればOK。
 * 既に同名のトリガーがある場合は重複作成しないようにしています。
 */
function setupTrigger() {
  const already = ScriptApp.getProjectTriggers().some(
    (t) => t.getHandlerFunction() === 'checkServerStatus'
  );
  if (already) {
    Logger.log('トリガーは既に設定済みです。');
    return;
  }
  ScriptApp.newTrigger('checkServerStatus')
    .timeBased()
    .everyMinutes(1)
    .create();
  Logger.log('毎分実行のトリガーを作成しました。');
}

// ---- メイン処理 ---------------------------------------------------------

/**
 * 毎分呼ばれるメイン関数。
 * サーバー状態を取得し、必要であればDiscordに通知する。
 */
function checkServerStatus() {
  const config = getConfig_();
  const result = fetchServerStatus_(config.serverAddress);
  const props = PropertiesService.getScriptProperties();

  const lastState = props.getProperty('lastState'); // 'online' / 'offline' / 'error'
  const lastNotifyAt = Number(props.getProperty('lastNotifyAt') || 0);
  const now = Date.now();

  const stateChanged = lastState !== null && lastState !== result.state;
  const aliveIntervalPassed = now - lastNotifyAt >= ALIVE_NOTIFY_INTERVAL_MS;

  // 初回実行時(lastStateがまだ無い)は必ず1回通知しておく
  const shouldNotify = lastState === null || stateChanged || aliveIntervalPassed;

  if (shouldNotify) {
    postToDiscord_(config.webhookUrl, config.authKey, config.serverAddress, result, stateChanged);
    props.setProperty('lastNotifyAt', String(now));
  }

  props.setProperty('lastState', result.state);
}

// ---- API呼び出し ---------------------------------------------------------

/**
 * mcstatus.io から状態を取得する。
 * 戻り値の state は 'online' / 'offline' / 'error' のいずれか。
 * 'error' は「mcstatus.io自体への通信に失敗した」ケース(サーバーのOffline理由とは別)
 */
function fetchServerStatus_(serverAddress) {
  const url = 'https://api.mcstatus.io/v2/status/java/' + encodeURIComponent(serverAddress);

  let response;
  try {
    response = UrlFetchApp.fetch(url, {
      method: 'get',
      muteHttpExceptions: true,
      headers: {
        'User-Agent': 'GAS-MC-Status-Monitor (contact: your-email@example.com)',
      },
    });
  } catch (e) {
    // タイムアウトやDNS失敗など、fetch自体が例外を投げるケース
    return {
      state: 'error',
      errorMessage: 'mcstatus.io への接続に失敗しました: ' + e.message,
    };
  }

  const code = response.getResponseCode();
  if (code < 200 || code >= 300) {
    // mcstatus.ioはエラー時、本文にプレーンテキストでエラー内容を返す
    return {
      state: 'error',
      errorMessage: 'mcstatus.io から異常なレスポンス (HTTP ' + code + '): ' + response.getContentText(),
    };
  }

  let data;
  try {
    data = JSON.parse(response.getContentText());
  } catch (e) {
    return {
      state: 'error',
      errorMessage: 'mcstatus.io のレスポンス解析に失敗しました',
    };
  }

  if (!data.online) {
    return {
      state: 'offline',
      offlineReason: guessOfflineReason_(data),
      raw: data,
    };
  }

  return {
    state: 'online',
    players: data.players
      ? { online: data.players.online, max: data.players.max }
      : null,
    motd: data.motd && data.motd.clean ? data.motd.clean : null,
    version: data.version && data.version.name_clean ? data.version.name_clean : null,
    raw: data,
  };
}

/**
 * mcstatus.ioのレスポンスから、Offlineの推定理由を組み立てる。
 * mcsrvstat.usと違い、詳細なdebug情報(ping/query成否など)は返ってこないため、
 * 判別できる範囲での簡易メッセージになる。
 */
function guessOfflineReason_(data) {
  if (data.eula_blocked) {
    return 'MojangによってこのアドレスがEULA違反でブロックされています(Notchianクライアントから接続不可)';
  }
  if (data.ip_address === null) {
    return 'ホスト名の名前解決(DNS)に失敗しました。アドレスが正しいか確認してください';
  }
  return 'サーバーからの応答がありません(起動していないか、タイムアウトした可能性があります)';
}

// ---- デバッグ用ヘルパー ---------------------------------------------------

/**
 * 現在保存されている状態(lastState / lastNotifyAt)を確認したい時に実行する。
 * 実行後、「表示」→「ログ」または Ctrl+Enter でログを確認してください。
 */
function debugState() {
  const props = PropertiesService.getScriptProperties();
  Logger.log('lastState: ' + props.getProperty('lastState'));
  Logger.log(
    'lastNotifyAt: ' +
      props.getProperty('lastNotifyAt') +
      ' (' +
      new Date(Number(props.getProperty('lastNotifyAt') || 0)) +
      ')'
  );
}

/**
 * 通知の間引きロジックを無視して、強制的に1回Discordへ送ってみたい時に実行する。
 * 動作確認・デバッグ専用。
 */
function forceSendTest() {
  const config = getConfig_();
  const result = fetchServerStatus_(config.serverAddress);
  postToDiscord_(config.webhookUrl, config.authKey, config.serverAddress, result, false);
}

/**
 * lastState / lastNotifyAt をリセットする。
 * 次回の checkServerStatus 実行時に、状態変化がなくても必ず1回通知される。
 */
function resetState() {
  const props = PropertiesService.getScriptProperties();
  props.deleteProperty('lastState');
  props.deleteProperty('lastNotifyAt');
  Logger.log('状態をリセットしました。');
}

// ---- Discord通知 ---------------------------------------------------------

function postToDiscord_(webhookUrl, authKey, serverAddress, result, isStateChange) {
  const embed = buildEmbed_(serverAddress, result, isStateChange);

  const res = UrlFetchApp.fetch(webhookUrl, {
    method: 'post',
    contentType: 'application/json',
    muteHttpExceptions: true,
    headers: {
      'X-Auth-Key': authKey, // Cloudflare Worker側の簡易認証用
    },
    payload: JSON.stringify({ embeds: [embed] }),
  });

  const code = res.getResponseCode();
  // Discord Webhookは成功時 204 No Content を返す
  if (code !== 204) {
    Logger.log('Discordへの送信に失敗した可能性があります。HTTP ' + code + ' / ' + res.getContentText());
  } else {
    Logger.log('Discordへの送信に成功しました。');
  }
}

function buildEmbed_(serverAddress, result, isStateChange) {
  const now = new Date();
  const timestampText = Utilities.formatDate(
    now,
    Session.getScriptTimeZone(),
    'yyyy/MM/dd HH:mm:ss'
  );

  let title;
  let color;
  let fields = [];

  if (result.state === 'online') {
    title = isStateChange ? '🟢 サーバーがオンラインになりました' : '🟢 生存確認: オンライン';
    color = 0x57f287; // 緑
    if (result.players) {
      fields.push({
        name: 'プレイヤー数',
        value: `${result.players.online} / ${result.players.max}`,
        inline: true,
      });
    }
    if (result.version) {
      fields.push({ name: 'バージョン', value: String(result.version), inline: true });
    }
    if (result.motd) {
      fields.push({ name: 'MOTD', value: result.motd, inline: false });
    }
  } else if (result.state === 'offline') {
    title = isStateChange ? '🔴 サーバーがオフラインになりました' : '🔴 生存確認: オフライン';
    color = 0xed4245; // 赤
    fields.push({ name: '推定理由', value: result.offlineReason, inline: false });
  } else {
    // error
    title = '⚠️ 監視APIへの接続に失敗しました';
    color = 0xfee75c; // 黄
    fields.push({ name: '詳細', value: result.errorMessage, inline: false });
  }

  return {
    // Discordのマークダウン見出し(#)を使うため、titleフィールドは空にしている
    title: '',
    description: `# ${title}\nサーバー: \`${serverAddress}\``,
    color: color,
    fields: fields,
    footer: { text: `最終確認: ${timestampText}` },
  };
}

そしたら、キーの設定をしていきます。画面左側の歯車マークを押して「プロジェクトの設定」を開きます。

エディターの左側にあるよ

画面が変わると思うので、そのページの一番下の「スクリプト プロパティ」の「スクリプト プロパティを編集」を押して、3つのプロパティと値をそれぞれセットしてください。
プロパティはDISCORD_WEBHOOK_URL
値はプロパティと異なります!先ほどCloudflareでコピーしたリンクをここに貼り付けてください。
2つ目のプロパティはSERVER_ADDRESS
値は監視したいサーバーのIPを入力しましょう。ポートが25565ではない場合はIP[URL]:PORTって書き方にしましょう。
3つ目はWORKER_AUTH_KEY
値は、CloudflareでAUTH_KEYで設定した文字列を入力してください。間違えると送信できません。
そしたら保存。
次にエディタに戻って、「forceSendTest」を選択して、実行を押してください。

多分もうすぐ完成するよー

Discord側にメッセージが送信されていれば成功です。
まだ完成じゃないからね!
そしたら、setupTriggerを選択して実行して、ログに「毎分実行のトリガーを作成しました。」とか表示されれば毎分トリガーが設定されて勝手に動いてくれます!お疲れ様でした!

トラブルシューティング?

一応エラーとか書いておきますね

Discord側では、次のメッセージが通達されます
MojangによってこのアドレスがEULA違反でブロックされています(Notchianクライアントから接続不可)
→サーバーが悪いことをしたそうで、Mojangから消されてるそうです。
ホスト名の名前解決(DNS)に失敗しました。アドレスが正しいか確認してください
→入力されたURLからIPに辿れなかったようです。URL間違えてないかご確認を。
サーバーからの応答がありません(起動していないか、タイムアウトした可能性があります)
→サーバーから返答がなかったそうです。単にサーバーが落ちていたり、ポートが解放できていなかったり、APIの噛み合わせのエラーだったり色々考えられます。

Discord側にメッセージすら来ない場合はGAS側で何かが起きているかも知れません。Cloudflareは単純なので考えにくいです。
GASで「resetState」と「forceSendTest」を手動で実行してみてください。ログにこんな感じでエラーが出るかも知れません
Discordへの送信に失敗した可能性があります。HTTP ***
→通達する過程でエラーが起きてるそうです。HTTPとか、4桁コードを検索すれば恐らくわかります。

おわりに?

お疲れ様でした!私は日本語が嫌いなので下手くそかも知れませんがClaudeが教えてくれたり書いてくれたコードを頑張って翻訳してみました!今後も気分で書いていく予定なので良かったら今後ともよろしくお願いします~

いいなと思ったら応援しよう!