🗂

原神の素材のリポップ時間を管理するためのdiscord botをcloudflare workers + honoで作った

2024/01/04に公開

はじめに

最近、原神というオープンワールドRPGを始めました。思いの外楽しく、コツコツとストーリーや探索を進めたりしているのですが、原神の一つ大事な要素の一つにキャラクターの育成があります。
キャラクターを育てるには、キャラクターごとに必要な素材を必要数集めなければなりません。この素材というのは、ボスを倒したりマップ中に点在しているアイテムを拾ったりすることで集めていきます。
ここからが話の本題なのですが、マップに点在しているアイテムは一度拾ってから48時間後にリポップ (再び拾えるようになる) します。なので、効率よくアイテムを集めようと思ったらアイテムがいつリポップするのかを把握する必要があります。最初は己の記憶を信じていたのですが、「あれ?xxxxって採ったのいつだっけ?」が頻繁に起きたので信じるのをやめました。やはり記録!記録をしないとだめだぞ!という気持ちになったのでそういうアプリを作ることにしました。
要件はすごいシンプルで、「そのアイテムが今リポップしているか否かを調べれる」というだけです。

なんでDiscord bot?

Webアプリケーションであったりスマホアプリとかも選択肢としてはあると思うんですが、今回はDiscord botをユーザーとのインターフェースとして利用することにしました。
その理由としては

  • 私が普段PCで原神をしているので、Discordは常に起動していてシュッと見ることができる
  • Discordのサーバー上で動かすものなので、アカウント管理とか考えなくてよさそう
    • Discordのアカウント情報を使えばユーザーを識別できる
  • Discord bot作ってみたい!

といった感じです。
Discord botがユーザーとインタラクションする方法は 公式のドキュメント に書いてあるとおり以下の3種類です。

  • CHAT_INPUT: チャット欄に/commandと打ってbotに送信する
  • USER: UI上でbotユーザーを右クリックしてアプリからコマンドを選ぶ
  • MESSAGE: UI上でbotユーザーのメッセージを右クリックしてアプリからコマンドを選ぶ

チャット欄にメモをするノリで記録できたり問い合わせたりできたほうが楽だなと考え、今回はCHAT_INPUTでコマンドを作っていくことに決めました。

採用した技術

Cloudflare workers

ユーザーから送信されたインタラクションを受け取り、レスポンスを返すアプリケーションを用意する必要があります。公式ドキュメント
今回はそのアプリケーションのデプロイ先としてCloudflare workersを採用しました。採用理由としては環境の用意であったりデプロイであったりが簡単にできるという点が大きいです。wrangler というCLIツールを使えばデプロイやD1やKVといった関連リソースの作成が簡単にできます。

D1

データの永続化にはCloudflare D1というサーバーレスなDBを使います。SQLiteなので使える型が少なかったり、トランザクションが張れなかったりとちゃんとやろうとするのは難しいんですが、今回は1つのテーブルに素朴にupsert or selectくらいしかしないのでD1を使うことにしました。いつの間にかmigrationがwranglerコマンドでできるようになっていてDDLの管理も楽になったのもGoodポイントでした。

hono

Cloudflare workers上で動作するアプリケーションは、honoというフレームワークを利用して書いていきます。

  • cloudflare workers向けのアプリケーションの雛形が npm createでシュッと作れる
  • 最近流行っていそうだし、自分もちょいちょい使っている
    あたりが理由で採用しました。他の選択肢としてexpressくらいしか知らないというのもありますが... (typescriptでworkersに載せれるapiサーバーシュッと作るなら他に選択肢何があるんだろう)

discord-interactions

このbotを作るに当たり、一番お世話になったライブラリです。discord-interactions にはdiscord botを作るにあたって便利な型定義であたりヘルパー関数が用意されています。

実践編

このアプリケーションのコードはこのリポジトリに置いてあります。

Discord Developer Portal上でbotを登録してcloudflare workersと連携する部分は、以下の公式のチュートリアルを参考にすればできるので割愛します。
公式のチュートリアル

この記事ではユーザーからのインタラクションをWeb hookで受取り、メッセージを返すCloudflare workersのアプリケーションのコードを詳しめに書いていこうと思います。

認証

botから飛んできたリクエストが本当にDiscordから飛んできたものなのかを確認する (これを認証と言ってよいのか若干悩ましいですが) 方法として、リクエストヘッダーのX-Signature-Ed25519X-Signature-Timestampの値とアプリケーション側に持たせているDiscord dev portalで発行した公開鍵を用いて確認するというのがあります。コード例としては以下のような感じです。

import { verifyKey } from "discord-interactions";

async function verifyKeyMiddleware(c: Context, next: Next) {
  const signature = c.req.header("X-Signature-Ed25519") ?? "";
  const timestamp = c.req.header("X-Signature-Timestamp") ?? "";
  const raw = await c.req.raw.clone().text();
  const isValid = verifyKey(raw, signature, timestamp, c.env.PUBLIC_KEY);
  if (!isValid) {
    return c.json(
      {
        type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
        data: { message: "invalid request" },
      },
      401,
    );
  }
  return next();
}

肝心の確認する部分に関しては先述のdiscord-interactionsがverifyKeyというヘルパー関数を用意していたのでそれを使うだけでした。

Pingへの対応

公式のドキュメント的にはこのへんに書いてあるんですが、web hookでアプリケーションにリクエストを飛ばす際、本命のリクエストを送る前に送信先のアプリケーションが応答できるか?を確認するPingリクエストが飛んできます。このPingリクエストに対してはステータスコード200, ペイロードに {type: 1}というJSON文字列を返して上げる必要があります。

import { InteractionType } from "discord-interactions";

app.post("/", verifyKeyMiddleware, async (c) => {
  const body = await c.req.json();
  switch (body.type) {
    case InteractionType.PING: {
      return c.json({ type: 1 }, 200);
    }
    case InteractionType.APPLICATION_COMMAND: {...}
  }  
}

honoで書くと上みたいな感じです。特筆すべき点は body.typeのswitch文の部分で、公式ドキュメントに記載されているようにアプリケーションに飛んでくるリクエストの中に、どういうインタラクションなのかがわかるようになっています。
Pingの場合は専用のインタラクションタイプが用意されているのでインタラクションタイプを見て分岐させることができます。

コマンドの登録

コマンドの実際の処理を書いていく前に、Discord botに対してコマンドを登録する必要があります。登録の仕方は公式ドキュメントにあるのですが、HTTPリクエストを投げることで登録をします。今回はGo言語で登録するスクリプトをシュッと用意しました。100行くらいで書けちゃいます。

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/joho/godotenv"
)

type Command struct {
	Name        string          `json:"name"`
	Description string          `json:"description"`
	Options     []CommandOption `json:"options"`
}

type CommandOption struct {
	Type                    int               `json:"type"`
	Name                    string            `json:"name"`
	NameLocalization        map[string]string `json:"name_localization"`
	Description             string            `json:"description"`
	DescriptionLocalization map[string]string `json:"description_localization"`
	Required                bool              `json:"required"`
}

func main() {
	if err := godotenv.Load("../.dev.vars"); err != nil {
		log.Fatalf("failed to load env vars: %v", err)
	}
	applicationID := os.Getenv("APPLICATION_ID")

	Commands := []Command{
		{
			Name:        "register",
			Description: "register got item",
			Options: []CommandOption{
				{
					Type:                    3,
					Name:                    "item",
					NameLocalization:        map[string]string{"ja": "素材名"},
					Description:             "item name",
					DescriptionLocalization: map[string]string{"ja": "登録したい素材名"},
					Required:                true,
				},
				{
					Type:                    3,
					Name:                    "duration",
					NameLocalization:        map[string]string{"ja": "リポップ時間"},
					Description:             "duration of repop",
					DescriptionLocalization: map[string]string{"ja": "素材がリポップするのにかかる時間"},
					Required:                true,
				},
			},
		},
		{
			Name:        "verify",
			Description: "verify if item is respoped",
			Options: []CommandOption{
				{
					Type:                    3,
					Name:                    "item",
					NameLocalization:        map[string]string{"ja": "素材名"},
					Description:             "item name",
					DescriptionLocalization: map[string]string{"ja": "登録したい素材名"},
					Required:                true,
				},
			},
		},
	}
	body, err := json.Marshal(Commands)
	if err != nil {
		log.Fatalf("failed to marshal command: %v", err)
	}

	req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("https://discord.com/api/v10/applications/%s/commands", applicationID), bytes.NewBuffer(body))
	if err != nil {
		log.Fatalf("failed to create request: %v", err)
	}
	req.Header.Add("Content-Type", "application/json")
	req.Header.Add("Authorization", fmt.Sprintf("Bot %s", os.Getenv("TOKEN")))

	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatalf("failed to post command: %v", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		log.Fatalf("failed to post command: %v", resp.Status)
	}
}

今回登録したコマンドは2つ

  • /register item duration: 素材とリポップにかかる時間を登録するコマンド
  • /verify item: 登録した素材がリポップしたかどうかを確認するコマンド

コマンドのデータ構造がどうなっているかは公式ドキュメントの定義を見てください。
引数はOptionという形でコマンドの中に組み込むことができます。

ちなみにコマンドの登録が完了した状態でbotを使いたいサーバーの招待すると、テキストチャットでスラッシュを打つと登録されたコマンドの補完が出てきます。

localizationが機能しない

コマンドのデータ構造の中にXXXLocalizationという属性があります。これは辞書型の構造をしていて、キーとなるlocalに対してコマンド名や説明を対応するvalueで翻訳してくれるというものです。上の登録するスクリプトにあるように、ja (つまり日本語)のときは日本語で引数名や説明を出すよう登録してみたのですが手元のdiscord上ではlocalizationされませんでした。一旦英語でも使えないことはないので後回しにしていますが原因を探ってなんとかしたいことの一つです。

/registerコマンドの実装

本題となるコマンドの中身を実装していきましょう。
まず、このコマンドは2つの引数を取ります。

  • item: 素材名
  • duration: リポップにかかる時間 (e.g. 30m, 1h, 2d etc...)

durationをかかる時間+単位のイニシャルという形にしたのは直感的に入力しやすいかなと考えたからです。一方で、

  • アプリケーション側でパースを頑張らなきゃいけない
  • 1d3hみたいな合わせ技には対応してない

といったデメリットも考えられたので、単位をhourで決め打って数字のみをもらうというのも考えました。個人的には「hour以外の粒度 -> hourへの変換」が煩わしくなりそうだなと感じたので採用しませんでした。こういったdurationを入力させるということはよくあると思うんですが他にどういう選択肢があるのかは気になっています。

話を戻してコマンドの実装ですが、まずは/registerコマンドが来たときの受け口を作ります。

index.ts
import { register } from "./command/register";

app.post("/", verifyKeyMiddleware, async (c) => {
  const body = await c.req.json();
  switch (body.type) {
    case InteractionType.PING: {
      return c.json({ type: 1 }, 200);
    }
    case InteractionType.APPLICATION_COMMAND: {
      switch (body.data.name) {
        case "register": {
          const res = await register(c.env.DB, {
            registerUserId: body.member.user.id,
            itemName: body.data.options[0].value,
            duration: body.data.options[1].value,
          });
          return c.json({
            type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
            data: { content: res },
          });
        }
      }
    }
  }	
}

先ほど作ったPINGとは違い、これから作っていくスラッシュコマンド(/hogeをテキストチャットで送信する形式)はInteractionType.APPLICATION_COMMANDという種類です。スラッシュコマンドの種類を分別するにはリクエストボディの中にコマンド名が入っているのでそれを使います。レスポンスをどういうデータ構造で返すべきかはここを参考にしてください。
honoのルーティング部分を主に書いているindex.tsではリクエストボディの中身から欲しいものを取り出し、所望の形でレスポンスを返すという仕事に徹するようにして、コアとなる部分(/registerでいうとデータを登録する部分)は別のモジュールに切り出すことにしました。切り出したregister()関数の実装は以下のとおりです。

command/register.ts
import dayjs, { ManipulateType } from "dayjs";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);

export type RegisterInput = {
  registerUserId: number;
  itemName: string;
  duration: string;
};

export async function register(
  db: D1Database,
  input: RegisterInput,
): Promise<string> {
  const durationNum = parseInt(input.duration.match(/\d+/)?.[0] || "0");
  const durationUnit = input.duration.replace(/\d+/g, "");
  const now = dayjs();
  const endTimeStamp = now.add(durationNum, durationUnit as ManipulateType);

  try {
    await db.prepare(
      `INSERT INTO repop_items
			  (discord_user_id, item_name, start_timestamp, end_timestamp)
			  VALUES (?1, ?2, ?3, ?4)
			  ON CONFLICT (discord_user_id, item_name) DO UPDATE SET start_timestamp = excluded.start_timestamp, end_timestamp = excluded.end_timestamp
			  `,
    )
      .bind(
        input.registerUserId,
        input.itemName,
        now.toISOString(),
        endTimeStamp.toISOString(),
      )
      .run();
  } catch (e) {
    if (e instanceof Error) {
      return e.message;
    }
    return JSON.stringify(e);
  }
  return `registerd: ${input.itemName}, it will be repoped at ${endTimeStamp
    .tz("Asia/Tokyo")
    .format()}`;
}

DBにはdurationをそのままいれるのではなく、素材を採取した時刻とリポップするであろう時刻をいれることにしました。そのためdurationを頑張ってパースしています。(もっと良い書き方知りたい)
時刻の計算や出力にはdayjsというライブラリを使いました。APIが使いやすいというのと軽量(と謳われている)ので採用しています。また、同じアイテムを異なるユーザーが登録することを考えてdiscord上のユーザーIDもいれるようにしています。

これで/registerコマンドの実装はできたので、実際にコマンドを叩いて動作を見てみましょう。

コマンドを送信すると

とメッセージが返ってきました。パッと見よさそうですがDBにちゃんとデータが保存されているのかも確認しましょう。D1はCloudflareのダッシュボードからコマンドやSQLを叩けるので今回はそれで確認しました。wrangler cli経由でもいいと思います。

/verifyコマンドの実装

次に、登録した素材がリポップしたかどうかを確認する/verifyコマンドを実装していきます。index.tsの部分は/registerとほとんど変わらないので割愛して、コアとなる実装が書かれているverify.tsを以下に載せます。

command/verify.ts
import dayjs from "dayjs";
import { RepopItem } from "../model/item";
import utc from "dayjs/plugin/utc";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(utc);
dayjs.extend(timezone);

type VerifyInput = {
  userId: number;
  itemName: string;
};

export async function verify(
  db: D1Database,
  input: VerifyInput,
): Promise<string> {
  const userId = input.userId;
  const itemName = input.itemName;
  try {
    const res = await db
      .prepare(
        "SELECT item_name as itemName, start_timestamp as startTimeStamp, end_timestamp as endTimeStamp FROM repop_items WHERE item_name = ?1 and discord_user_id = ?2",
      )
      .bind(itemName, userId)
      .first<RepopItem>();
    if (!res) {
      return "item not registered";
    }

    if (dayjs().isAfter(res.endTimeStamp)) {
      return "item repoped";
    }
    return `${res.itemName} is not repoped yet. it will be repoped at ${dayjs(
      res.endTimeStamp,
    )
      .tz("Asia/Tokyo")
      .format()}`;
  } catch (e) {
    if (e instanceof Error) {
      return e.message;
    }
    return JSON.stringify(e);
  }
}

やってることは単純で、リクエストから取りだした素材名とユーザーIDからレコードをSELECTで探し出して現在時刻とendTimeStampを比較しているだけです。まだリポップしてなかったときはいつリポップするのかを表示するようにしました。
それではこちらのコマンドも動作確認してみましょう。

さっき登録した風車アスターという素材がリポップしたか調べてみると

まだリポップしてないよ〜というメッセージがちゃんと出てきました。

リポップしたときにどうなるかも確認したいので、durationを10秒にした素材を登録して見てみます。

こんな感じでリポップしたことをちゃんと表示してくれました。

今後できるようにしたいこと

本当に最低限自分ができたらいいなと思っていた機能は実現できましたが、作っていく中でいくつか思い浮かんだ機能や、改善ポイントがあったので備忘録としてここに書いておきます。

  • 登録した素材の一覧コマンド
    • いちいち素材名を指定せずに、今リポップしているアイテム一覧を見せろ!ということもあるだろうなと思った
    • [追記 2024-01-08T12:30:00+09:00] /listコマンドを追加しました。コマンドを打った時点でリポップしている素材名の一覧が返ってきます。
  • 定期的な通知
    • 毎日決まった時間にリポップしたアイテム一覧を自動でメッセージしてくれるみたいな機能
    • guild scheduled eventというものがあるらしいのでやればできそう
  • テスト
    • Discord botはinteraction URL (リクエストの送り先) を1つしか登録できない都合上コマンドのデバッグをしようと思ったら都度デプロイする必要がありました
    • workersは幸いデプロイが一瞬なのであんま苦じゃなかったんですが、テストできたほうがいいなという気持ちになりました
    • honoならこの辺を参考にシュッと書けるんじゃないかなーと思ってます

終わりに

まず、原神をやっているという人で興味が湧いたらぜひ一度使ってもらえると嬉しいです。フィードバックをもらえると更に嬉しいです。また、自分は普段Go言語ばっかり書いていてTypescriptをあまり書いてこなかったので、コードという観点でもフィードバックをもらえたらいいなと思い記事を書きました。最後まで読んでいただきありがとうございました。

今回作成したDiscord botはinvite URLからサーバーに招待することができます。

Discussion