DiscordBotによる謎解きアプリの作り方 解説

こんにちは。久しぶりの投稿です〜。今回は趣味全開記事です。

ここ数年、リアルタイム脱出とか謎解きとか流行ってますよね。謎解きを自作されてる方も結構いると思います。
自分もプライベートで程々に、オフライン・オンライン謎解き作ってきましたので、今回はDiscord botによる謎解きアプリの作り方を解説していきます。

Discordはテキストや通話だけでなく、ロールの権限付与で可視範囲をコントロール出来たり非常に表現が豊かなので、謎解きアプリ的には結構面白いことが出来ると思ってます。一方で実在する例が少なかったり、デプロイどこにする問題があったり一長一短です。

## もくじ
「Discord bot 作り方」で検索すると記事たくさん出てきますが、内容が古いものも多くコピペしても動かないものもあるので、全部軽めに解説します。

- 実装周り解説
- botの作り方
- サーバの準備
- 遊んでみた
- サンプルコード紹介

の構成で行きます。不要な章は適宜飛ばしてください！

## 実装周り解説
今回はnode.js(v16.6)で実装しました。discord botでググるとPythonの例とかあるので言語は好みです。
後述するTOKENを使い、返事するbotの実装をして動かしてみましょう！

```js
const Discord = require('discord.js');
const client = new Discord.Client({ intents: [Discord.Intents.FLAGS.GUILDS, Discord.Intents.FLAGS.GUILD_MESSAGES] });

// ベタ書き良くないですが、今回はかんたんな例なのでここに書いていきますね
const TOKEN = "後述するTOKENを記載"

client.once('ready', () => {
  console.log(`${client.user.tag} でログインしています。`)
})

// discordにメッセージが送信されると呼ばれる
client.on('messageCreate', message => {
  // botの発言を無視したい
  if (!message.author.bot) {
    // messageに色んな情報やメソッドがあるので後ほど解説
    message.channel.send("おはよう");
  }
}

client.login(TOKEN);
```

slackbot等と違い、discordbotは実装をどこかにデプロイしなきゃいけないです(ここがメンドイんよ)、今回はローカルで動かしますね。

![24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/0c191b8c-306f-ddfe-4446-c7a4eb533011.png)

botが招待されたdiscordサーバで適当に発言すると、botが返事してくれます。

![25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/238342e1-6318-ab9e-3d6e-000259b95417.png)

上記の例は「メッセージ送信」をトリガーに処理をしましたが、トリガーは他にもたくさんあります。

```js
// メッセージ
client.on('messageCreate', message => {
  console.log(`オウム返しするよ。${message.content}`)
});
// リアクション
client.on('messageReactionAdd', (reaction, user) => {
  console.log(`${user.tag}がしてくれたリアクションは${reaction.emoji.name}です`)
});
// サーバから退出
client.on('guildMemberRemove', member => {
  console.log(`さいようなら${member.displayName}`)
});

```

...などなど。
自分は[Discord.js Japan User Group](https://scrapbox.io/discordjs-japan/)をたくさん参考にしました。リアクション周りは、[リアクションされる時の処理](https://scrapbox.io/discordjs-japan/%E3%83%AA%E3%82%A2%E3%82%AF%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%95%E3%82%8C%E3%82%8B%E3%81%9F%E3%81%B3%E3%81%AB%E5%87%A6%E7%90%86%E3%82%92%E5%AE%9F%E8%A1%8C%E3%81%99%E3%82%8B)を見ましたし、ロール周りは[メンバーにロールの追加・削除したい](https://scrapbox.io/discordjs-japan/%E3%83%A1%E3%83%B3%E3%83%90%E3%83%BC%E3%81%AB%E5%BD%B9%E8%81%B7%E3%82%92%E8%BF%BD%E5%8A%A0%E3%80%81%E5%89%8A%E9%99%A4%E3%81%97%E3%81%9F%E3%81%84)あたりとかですね。

謎解きアプリ的には

- トリガー
  - 答えの送信
  - 特定のリアクション
- 返却値
  - テキストの返答
  - 画像の返却
  - ロールの付与/削除

あたりが使えそうですね！

問題に対して適切な答えをメッセージ送信していくタイプの謎解きを想定して `messageCreate` をもう少しだけ触れます。Messageクラスの中身を見てみると、プロパティとかメソッドとか色々見えてきますね。

```js
client.on('messageCreate', message => {
  // メッセージの中身
  const text = message.content;

  // メッセージしたメンバー(GuildMemberクラス)
  const member = message.member;
  // 付与されているrole一覧
  console.log(member.roles);
  // memberにroleを付与
  member.roles.add(/*付与したいRoleクラスを引数にわたす*/);

  // メッセージが送信されたチャンネル(Channelクラス)
  const channel = message.channel;
  // 該当チャンネルに返答する
  message.channel.send("botに喋らせる内容");
  message.channel.send({ files: ['./botが返す画像.jpg'] });

  // サーバ情報(Guildクラス)
  const guild = message.guild;
  // サーバにいるメンバーやチャンネル、ロール一覧はキャッシュから取る

  console.log(guild.members.cache);
  console.log(guild.channels.cache);
  console.log(guild.roles.cache);
});
```

自分が実装する上で気をつけなきゃ！と思った点がいくつかありまして

#### `members.cache` から取れないユーザがいる。
検証してもわからなかったのですが、恐らくdiscord上でアクティブ非表示にしているメンバーは、members.cacheから取れないので、謎解きを遊んでもらう時は表示ONにしてもらってます。

#### `ID` ベースでやりとりしないケースがある。
何か取得する時って `getXXXById` とかを想像すると思うのですが、discordサーバのテンプレを用いた運用だとサーバAサーバB作って両方のサーバにbot入れて、動かして・・・となると、ID決め打ちできないんですよね。

更に参加者が「チャンネルA」で回答を書いたら「チャンネルB」に次の問題を送る、みたいな処理を実現しようとすると
 `message.guild.channels.cache.find(c => c.name === "チャンネルB").send("次の問題")` みたいな実装になりますが `message.guild.channels` にチャンネルだけじゃなく「カテゴリ」も含まれちゃうんですよね。区別の仕方はもちろんあると思いますが、カテゴリとチャンネル、同名のもの作らない！が一番安全かもです。

なのでIDベースでやりたいところではありますが、名前ベースでfind、getせざるを得ないケースもあるので、そのあたりは要注意です。

## botの作り方
それでは謎解きアプリ作りましょう！サンプルは「3分で分るポケットモンスター赤緑」でいきましょう。流れとしてはこんな感じかな。

- 博士から御三家もらう
- ジム巡り
- チャンピョンになる
- ミュウツーゲット

ではオーキド博士botを作りましょう。世の中にたくさん記事あるのでざっくり目で。[Developer](https://discord.com/developers/applications)に入り「New Application」からbotを作りましょう。

![21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/c7a25e1e-f71e-0296-52c1-776b49a7c90b.png)

作成後botの詳細ページに進みます

- GeneralInformationは、表示名やアイコン周りの設定
- BotタブにてBuild-A-Bot追加。そうすると `TOKEN` が得られる！
- OAuth2のURLGeneratorで「SCOPESをbot」「BOT PERMISSIONSをAdministrator」にして以下のようなURLをゲット
  - ※ちゃんと権限設定したほうが良いかもしれないけど一旦これで作ってみよう

 `https://discord.com/api/oauth2/authorize?client_id=○○○○○&permissions=8&scope=bot` 

![22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/a8402f0a-141d-739c-f0c0-eccd5013f775.png)

botが必要なサーバにinviteする

![23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/ffd03e27-3060-4dd5-a748-6dc3ec207642.png)


これでbotの招待は完了です！

![26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/7e58cc8a-0362-4534-4959-2fbd459a9b25.png)


## サーバの準備
今回は簡単ロール運用でいきます(以前作ったアプリはロールが40個ぐらいあって大変だった。。)
注意事項としてbotをinviteすると、権限最下位になります。このbotは何も権限付与できませんw

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/3efebcf2-7818-e56b-5bcc-1b02be752f8e.png)


付与していく権限よりも高い権限にしてあげましょう。

![2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/bc7166c0-06a1-3923-7cd8-8366bdb27100.png)


サーバのテンプレートを作れば、チャンネル・カテゴリ・ロールの設定が全部引き継がれるので、便利です。

![27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/f3616133-6def-aeae-e478-6c9b47053d77.png)


## 遊んでみた
遊ぶ側がどんな体験が出来るのかイメージしやすいよう、スクショを交えて紹介します。
参加者の皆さんは、何の権限も付与されていないので「マサラタウン」チャンネルしか見れませんね。

![3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/d6a3882a-b1c0-8364-4d6f-7586b7c66faf.png)


「冒険開始」の合図とともに「研究所」チャンネルが見えてきましたね。裏では「冒険開始」ロールが付与されてます。

![4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/c3e43275-0d88-8ae2-3272-5edb2bf95bc9.png)


ヒトカゲ貰っておきましょう。画像の返却もばっちし！

![6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/5ceb3444-2b18-28b7-b00f-27913b238491.png)


ロールの運用だけで、こういう細かい出し分けもできます。他人のヒトカゲを強奪しちゃだめです！

![7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/67938900-9569-53d4-3883-aafb7184c10e.png)


本当はヒトカゲがほしかったライバル君は仕方なくフシギダネを貰いましょう。おや「中盤」カテゴリのチャンネルがたくさん見えてきましたね。
(どうでも良いですが、ここゼニガメを取るべきだったわ笑)

![8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/8a7a4f37-3d5e-8b8c-c01d-11d157852c5b.png)


ちなみにですがbotが反応する言葉を他のチャンネルに書き込んでも、反応しないようにすると完成度が高く見えますね。

![9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/67b817ea-b0a7-0da9-a6af-cf9b85ecf50f.png)


「おいしいみず」の言葉で「ヤマブキシティ」チャンネルを見れる権限をゲットします。

![10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/b4015b9e-f393-04f5-d669-3e737f7af09e.png)


本当に雑ですけど「終盤」カテゴリの「セキエイ高原」チャンネルが出てきました、これで四天王にチャレンジできますね！

![11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/4c6892a3-f1a8-1ca7-28f1-220bc37d4194.png)


よっしゃ！殿堂入り！RTA走者顔負けのスピード感でしたね。
おや、先程「中盤」カテゴリには無かった「ハナダの洞窟」チャンネルが見えてきましたね。

![12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/e0f132dc-a95f-2b46-cc20-517b082ddf86.png)


1割の確率でゲットできるように実装しました。ひたすらボールを投げましょう。

![13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/4ed6a879-7c3b-a7b9-2972-b1dbd70be9c0.png)


。。。。本当に確率10%か？？？？？？？

![14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/d93cdfdd-5c03-8db5-1e79-df7893ea0a49.png)


ということで「マサラタウン」しか見えなかったレッドも最終的にはこんなにチャンネルが解放されました。

![15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/95091/40db1b69-4141-4459-141f-b9a9e1750b73.png)


ざっくり流れがイメージ出来たかなと思います。ロールの付与削除でチャンネル見えなくしたり、リアクションやpinを使うことも出来るので、本当に表現が豊かです。
是非皆さんも、discordで謎解きアプリ作ってみましょ〜！

## サンプルコード
可読性とか一旦無視してやりたいことざっくり書いてます。参考になれば幸いです。

```js
const Discord = require('discord.js');
const client = new Discord.Client({ intents: [Discord.Intents.FLAGS.GUILDS, Discord.Intents.FLAGS.GUILD_MESSAGES] });

const TOKEN = "xxxxxxxxxxxx"

client.once('ready', () => {
  console.log(`${client.user.tag} でログインしています。`);
})

/**
 * messageCreate: chat内でメッセージが送信された時
 */
client.on('messageCreate', message => {
  /**
   * messageクラスプロパティ
   * guild: guildクラスは鯖情報を持つ。roles.cache、.channels.cache、.members.cacheで取得可能
   * member: 投稿者
   * content: テキスト情報
   * channel: 投稿されたチャンネル情報
   */
  // botの発言を除外する
  if (!message.author.bot) {
    // チャンネル取得関数。channelクラスを1つ返却。
    const findChannelByName = channelName => message.guild.channels.cache.find(c => c.name === channelName);
    // role取得関数。roleクラスを1つ返却。
    const findRoleByName = roleName => message.guild.roles.cache.find(r => r.name === roleName);
    // 特定のroleが付与されてるmemberを取得する関数。memberクラスを複数返却。
    const findMembersByRoleName = roleName => {
      const targetRole = findRoleByName(roleName);
      return message.guild.members.cache.filter(m => m._roles.includes(targetRole.id))
    }
    // role付与関数。返却値不要。
    const addRoleByRoleName = roleName => {
      // 引数のrole名から、付与したいroleを取得
      const targetRole = findRoleByName(roleName);
      // roleを付与したいメンバー = 冒険開始roleを持つメンバー
      const targetMember = findMembersByRoleName("冒険開始");
      //　roleの付与
      targetMember.forEach(m => m.roles.add(targetRole));
    }

    switch (message.content) {
      // 開始
      case "冒険開始":
        if (message.channel.name === "マサラタウン") {
          // 全員に付与するので addRoleByRoleName関数は使わない
          message.guild.members.cache.forEach(m => m.roles.add(findRoleByName("冒険開始")));
          findChannelByName("研究所").send("ゆめと ぼうけんと!ポケットモンスターの せかいへ!レッツゴー!");
        }
        break;
      // 御三家選び
      case "フシギダネ":
        if (message.channel.name !== "研究所") {
          break;
        }
        if (findMembersByRoleName("フシギダネ").size > 0) {
          findChannelByName("研究所").send("残ってないぞ");
          break;
        }
        // 投稿者にだけroleつける
        message.member.roles.add(findRoleByName("フシギダネ"));
        findChannelByName("研究所").send("フシギダネを選ぶんじゃな");
        findChannelByName("研究所").send({ files: ['./img/fushigidane.png'] });
        if (findMembersByRoleName("ヒトカゲ").size > 0 || findMembersByRoleName("ゼニガメ").size > 0) {
          addRoleByRoleName("旅開始");
          findChannelByName("研究所").send("それでは期待しておるぞ");
        }
        break;
      case "ヒトカゲ":
        if (message.channel.name !== "研究所") {
          break;
        }
        if (findMembersByRoleName("ヒトカゲ").size > 0) {
          findChannelByName("研究所").send("残ってないぞ");
          break;
        }
        // 投稿者にだけroleつける
        message.member.roles.add(findRoleByName("ヒトカゲ"));
        findChannelByName("研究所").send("ヒトカゲを選ぶんじゃな");
        findChannelByName("研究所").send({ files: ['./img/hitokage.png'] });
        if (findMembersByRoleName("フシギダネ").size > 0 || findMembersByRoleName("ゼニガメ").size > 0) {
          addRoleByRoleName("旅開始");
          findChannelByName("研究所").send("それでは期待しておるぞ");
        }
        break;
      case "ゼニガメ":
        if (message.channel.name !== "研究所") {
          break;
        }
        if (findMembersByRoleName("ゼニガメ").size > 0) {
          findChannelByName("研究所").send("残ってないぞ");
          break;
        }
        // 投稿者にだけroleつける
        message.member.roles.add(findRoleByName("ゼニガメ"));
        findChannelByName("研究所").send("ゼニガメを選ぶんじゃな");
        findChannelByName("研究所").send({ files: ['./img/zenigame.png'] });
        if (findMembersByRoleName("フシギダネ").size > 0 || findMembersByRoleName("ヒトカゲ").size > 0) {
          addRoleByRoleName("旅開始");
          findChannelByName("研究所").send("それでは期待しておるぞ");
        }
        break;

      // 中盤
      case "おいしいみず":
        if (message.channel.name.match(/ゲート/)) {
          addRoleByRoleName("通行OK");
          message.channel.send("マブキシティに行けるようになったぞ")
          findChannelByName("ヤマブキシティ").send("いらっしゃい");
        }
        break;
      case "ゴールドバッジ":
          if (message.channel.name === "ヤマブキシティ") {
            addRoleByRoleName("四天王戦");
            message.channel.send("ポケモンリーグ挑戦しにいこう")
            findChannelByName("セキエイ高原").send({ files: ['./img/quiz.jpg'] });
          }
          break;
      // 終盤
      case "はい":
        break;
      case "いいえ":
        addRoleByRoleName("殿堂入り");
        message.channel.send("おめでとう!");
        findChannelByName("ハナダの洞窟").send({ files: ['./img/myutsu.jpg'] });
        break;

      // おまけ
      case "モンスターボール":
        if (message.channel.name !== "ハナダの洞窟") {
          break;
        }
        // 確率1割
        if (Math.floor(Math.random() * 10) > 8) {
          addRoleByRoleName("ミュウツー");
          message.channel.send("やった!ミュウツーを手に入れた!")
        } else {
          message.channel.send("ダメだ!ボールから出てしまった!")
        }
        break;
      default:
        break;
    }
  }
})

/**
 * トークンを使って、botをオンラインにする
 */
client.login(TOKEN);
```