技術

TRPGの集いに Discord 連携を入れた話

はじめに

TRPG のセッション募集サイト「TRPGの集い」に Discord 連携を入れ、会員登録から募集・参加・リマインドまでを Discord の中で完結できるようにしました。サイトは Laravel 製で、サーバーは Rocky Linux で運用しています。

これまでは「サイトでセッションを公開する → Discord に手でリンクを貼る → 参加者に個別に声をかける」という流れで、主催者の手作業が多い状態でした。連携後にできるようになったことは次のとおりです。

  • Discord の /register から会員登録でき、Discord のロールに応じてサイトの権限が決まる
  • セッションを公開すると、通知チャンネルに投稿とスレッドが自動で作られ、ボタンから参加・キャンセルできる
  • 開始の24時間前と2時間前に、スレッドでリマインドが届く
  • 日程変更・中止・参加の取りやめが、スレッドと元の通知に自動で反映される
  • 今後のセッションが、Discord のサーバーイベントとして一覧に並ぶ
  • サイトのお知らせが、専用チャンネルにも投稿される

この記事では、それぞれの仕組みと、作ってみて気づいたことをまとめます。

全体像

管理画面での保存も、スケジューラのリマインドも、いったん jobs テーブルに積まれ、Discord API に送信するのはキューのワーカーだけです。反対に、Discord からの入力(/register やボタンの操作)は、Web アプリが直接受け取ります。

会員登録は /register から

会員登録は、Discord のスラッシュコマンド /register に一本化しました。コマンドを実行すると、本人にだけ見えるメッセージで登録用のリンクが届きます。リンクは1回限り・60分で切れるので、URL が他の人に渡っても使い回されません。

サイトの権限は、Discord のロールから決めます。

Discord のロールサイトでの権限
管理者ロール管理者
GM/KP ロール編集者(セッションを作成・編集できる)
なし閲覧者

ロールで権限を決めておくと、「誰を編集者にするか」をサイトの管理画面ではなく、Discord のロール付与だけで管理できます。

/register は登録用チャンネルでしか使えないようにしています。どのチャンネルでも実行できると、雑談の途中に登録用のやり取りが混ざってしまうためです。あわせて、以前の管理画面にあった手動の登録ページは削除しました。登録の入り口を一つにしておくと、「Discord には居るのにサイトに登録されていない人」が生まれにくくなります。

セッションの通知とスレッド

セッションを公開すると、通知チャンネルに埋め込み付きの投稿が届き、その投稿からスレッドが作られます。投稿には「参加」「キャンセル」「詳細」の3つのボタンが付いていて、サイトを開かずに参加の申し込みまで済ませられます。

1つのセッションにまつわるやり取りは、すべてこのスレッドに集めています。

  1. 公開:通知チャンネルに投稿し、スレッドを作る
  2. 参加:参加した人をスレッドに追加する(以降の通知がその人に届く)
  3. リマインド:開始の24時間前と2時間前に、スレッドに投稿する(日本時間で計算)
  4. 変更:日程変更・中止・参加の取りやめを、スレッドに投稿する。同時に元の通知も書き換えて、最新の日程や【中止】表示にする

元の通知まで書き換えているのは、チャンネルを後から見た人が古い日程を信じてしまわないようにするためです。スレッドだけに「日程を変えました」と書いても、通知チャンネルの一覧には変更前の日時が残り続けてしまいます。

サイトのお知らせも、投稿したタイミングで専用のお知らせチャンネルに流れます。管理画面には「Discordに通知」ボタンを用意して、通知を消してしまったときや、送信に失敗したときに送り直せるようにしました。

サーバーイベントとの同期

今後の公開セッションは、Discord のサーバーイベントにも自動で登録しています。サーバー名の下に「イベント」として並ぶので、通知チャンネルを遡らなくても、近いうちに何があるかが一目で分かります。

イベントは「外部イベント」(場所を文字で指定する種類)として作り、セッションの公開・編集・中止・削除に合わせて、登録・更新・削除します。ボイスチャンネルに紐づけないので、オンライン・オフラインどちらのセッションでも同じ扱いにできます。

この機能は DISCORD_EVENTS_ENABLED で切り替えられるようにしました。イベントの作成にはボットのロールに「イベントを作成」の権限が必要で、権限を付けるまでは機能を止めておけるようにするためです。

Discord への送信はすべてキュー経由

Discord API への送信は、すべて Laravel のキューのジョブにしました。画面の操作の中で直接 API を呼ばない、というのがこの連携でいちばん大事にした決まりです。

理由は3つあります。

  • 画面が待たされない:セッションを保存するたびに、投稿の更新・スレッドへの通知・イベントの更新と、複数の API 呼び出しが発生します。これを保存ボタンの処理の中でやると、Discord の応答が遅いだけで管理画面が固まります
  • 失敗しても保存は成功する:Discord が落ちていても、セッションの保存自体は完了し、送信だけが後から再試行されます
  • レート制限に強い:送信が1本のワーカーに並ぶので、一度に大量の API 呼び出しが飛びません

キューのドライバーは database にしました。Redis などを追加せずに、既存の MariaDB だけで動かせるからです。この規模なら性能も十分です。

ワーカーは systemd のサービスとして常駐させ、デプロイのスクリプトで毎回再起動しています。ワーカーは起動時にコードと .env を読み込んだまま動き続けるので、再起動しないと古いコードのまま送信を続けてしまいます。

リマインドのように「決まった時刻に送る」処理は、Laravel のスケジューラに任せています。cron には毎分 php artisan schedule:run を実行する1行だけを登録し、何をいつ実行するかはコードの中で管理しています。

ハマりどころと学び

削除のあとに動くジョブには、値を渡しておく

いちばん考えさせられたのが、セッションを削除したときの処理です。ジョブは削除がコミットされたあとに動くので、その時点ではセッションの行も、参加者の記録も消えています。ジョブの中で「セッション ID から読み直す」と、何も見つかりません。

そこで、削除の直前にスレッド・メッセージ・イベントの ID、送る文面、メンションする参加者を集めて、ジョブにそのまま渡すようにしました。参加者はデータベースの連動で削除と同時に消えるため、削除の前に読み込むためのオブザーバーも追加しています。キューを使うなら、「ジョブが動くときに、元のデータがまだあるとは限らない」を前提にしておくのが安全です。

「削除」と「中止」の扱いを揃える

最初の実装では、セッションを削除してもサーバーイベントが消えるだけで、通知チャンネルには参加ボタン付きの投稿が残っていました。ボタンを押すと「見つかりません」と返る、ちょっと気まずい状態です。管理者が「中止」ではなく「削除」を選ぶことは普通にあるので、削除したときも中止と同じ処理(中止の通知・【中止】表示・ボタンの削除・イベントの削除)をするように直しました。

消したいなら、メッセージ ID を保存しておく

お知らせは最初、投稿したメッセージの ID を保存していなかったので、サイトで削除しても Discord の投稿は残っていました。Discord の投稿をあとから編集・削除したいなら、投稿したときに ID を保存しておくしかありません。今はお知らせを削除すると、Discord の投稿も消えます。Discord 側ですでに手で消されていた場合(404)は、失敗扱いにしないようにしています。

表示時刻のタイムゾーンを確かめる

リマインドは日本時間で計算していますが、状態確認のコマンドが表示する時刻は UTC です。「最終実行 09:08」と出ていても、日本時間では 18:08 のことです。リマインドが届いたかを確かめるときは、9時間ずらして読む必要があります。どの時刻がどのタイムゾーンかは、表示にも書いておくと迷いません。

ボットの権限は最小限に

ボットには管理者権限を付けず、必要なものだけをチャンネルごとに付けています。

付与先権限
通知チャンネルチャンネルを見る、メッセージを送信、埋め込みリンク、公開スレッドの作成、スレッドでメッセージを送信
お知らせチャンネルチャンネルを見る、メッセージを送信、埋め込みリンク
ボットのロールイベントを作成

自分の投稿の編集・削除や、自分が作ったイベントの更新・削除には、追加の権限は要りませんでした。

運用

キューとスケジューラは、画面に何も出さずに裏で動きます。止まっていても気づきにくいので、確認の手段を2つ用意しました。

1つ目は、状態をまとめて表示する Artisan コマンドです。

$ php artisan app:background-status

   INFO  スケジューラ: 正常(最終実行 09:29:02).

   INFO  キュー: 正常(待機中 0 件).

スケジューラが直近に動いているか、キューにジョブが溜まっていないかが、1回で分かります。送信に失敗したジョブは php artisan queue:failed で確認します。

2つ目は、サーバー管理用に作っている自作のパネル(ops-panel)です。サービスの稼働状況の一覧に、キューのワーカーを加えました。Apache や MariaDB と並んで表示されるので、パネルを開いたついでに止まっていないかを確認できます。

設定は、すべて .env の DISCORD_* にまとめています。

環境変数内容
DISCORD_APPLICATION_ID / DISCORD_PUBLIC_KEY / DISCORD_BOT_TOKENアプリとボットの認証情報
DISCORD_GUILD_ID対象の Discord サーバー
DISCORD_REGISTRATION_CHANNEL_ID/register を使えるチャンネル
DISCORD_REGISTRATION_TOKEN_TTL_MINUTES登録リンクの有効期限(分)
DISCORD_REQUIRED_ROLE_ID登録に必要なロール(空なら制限なし)
DISCORD_ADMIN_ROLE_ID / DISCORD_EDITOR_ROLE_ID管理者・編集者にするロール
DISCORD_NOTIFICATION_CHANNEL_IDセッションの通知チャンネル
DISCORD_ANNOUNCEMENT_CHANNEL_IDお知らせチャンネル
DISCORD_REMINDER_TIMEZONEリマインドの時刻を計算するタイムゾーン
DISCORD_EVENTS_ENABLEDサーバーイベントとの同期のオン・オフ

チャンネルやロールを ID で指定しているので、名前を変えても設定を直す必要はありません。

今後やりたいことと、まとめ

次に入れたいのは、次の3つです。

  • /sessions などのスラッシュコマンドで、Discord から募集中のセッションを探せるようにする
  • Discord のロールを付け外ししたとき、サイトの権限も自動で追従させる
  • サイトに Discord アカウントでログインできるようにする

Discord 連携を入れてみて、いちばん効いたのは「1つのセッションのやり取りを1つのスレッドに集める」設計でした。募集・参加・リマインド・変更の連絡が同じ場所に並ぶので、参加者はスレッドを見ていれば迷わず、主催者が個別に声をかける手間も減らせます。

実装の面では、Discord への送信をキューに寄せたことで、画面の速さと送信の確実さを両立できました。その代わり、「ジョブが動くときには元のデータが消えているかもしれない」「ワーカーは再起動しないと新しいコードを読まない」といった、非同期ならではの注意点が増えます。そこを最初から意識しておくと、後から直す手間が減ると思います。

この記事を書いた人

雪白深冬

TRPG とゲームが好きで、遊ぶための道具を作ったりサーバーを動かしたりしています。

← 記事一覧へ