ひぃの雑多書き

日記からお役立ち情報まで色んなことを書きます。

【v14】discordjsのすゝめ 第5回「inviteコマンドの実装」

こんにちは。ひぃです。

discordjsもある程度理解できたということで書き始めた「discord botの作り方」の第5回です。
今回はinviteコマンドの実装をしていきます。

◇環境
node: 16.13.2
discordjs: 14.7.1

5.inviteコマンドの実装

今回目指す成果

今回はこんな感じでゲームの募集をすることができるコマンドを作ります!

ファイル構成

ファイル構成はこんな感じです。

commandsのサブフォルダにguildonlyを作成し、invite.jsを追加してます。
同じコマンド関連でルート配下にhandlersフォルダが増えていて、invite_handler.jsを追加しました。

あとはルート配下にfunctions.jsが増えてます。

string.js

いつも通り文言が色々増えてます。

/** 
 * コマンド名、説明文
 */
exports.HELLO = "hello";
exports.HELLO_DESCRIPTION = "挨拶をします。";

exports.INVITE = "invite";
exports.INVITE_DESCRIPTION = "ゲーム等の募集を行います。";

exports.PING = "ping";
exports.PING_DESCRIPTION = "botが正常に動作しているかをチェックします。";

/** 
 * コマンドオプション
 */
exports.HELLO_OPTION_TIMES = "times";
exports.HELLO_OPTION_TIMES_DESCRIPTION = "時間帯を選択してください。";

exports.HELLO_OPTION_TIMES_NAME_MORNING = "朝";
exports.HELLO_OPTION_TIMES_NAME_NOON = "昼";
exports.HELLO_OPTION_TIMES_NAME_NIGHT = "夜";

exports.NUMBER_MORNING = 1;
exports.NUMBER_NOON = 2;
exports.NUMBER_NIGHT = 3;

exports.INVITE_OPTION_TITLE = "title";
exports.INVITE_OPTION_TITLE_DESCRIPTION = "募集タイトルを記載してください。";
exports.INVITE_OPTION_MESSAGE = "message";
exports.INVITE_OPTION_MESSAGE_DESCRIPTION = "募集の本文を記載してください。";

/** 
 * EMBED
 */
exports.EMBED_TITLE_SUCCESS = "Success!!";
exports.EMBED_TITLE_PREFIX_INVITE_NOW = "【募集中】";
exports.EMBED_TITLE_PREFIX_INVITE_CLOSED = "【終了】";

exports.EMBED_FOTTER_INVITE_NOW = "募集を終了するには募集者または管理者が🚫でリアクションをしてください";
exports.EMBED_FOOTER_INVITE_CLOSED = "この募集は終了しました。";

exports.EMBED_INLINE_INVITE_YES = "⭕参加";
exports.EMBED_INLINE_INVITE_MAYBE = "⚠️興味アリ";
exports.EMBED_INLINE_INVITE_NO = "❌不参加";

/** 
 * MESSAGE
 */
exports.HELLO_MESSAGE_MORNING = "おはようございます!";
exports.HELLO_MESSAGE_NOON = "こんにちは!";
exports.HELLO_MESSAGE_NIGHT = "こんばんは!";

exports.INVITE_CLOSE_CONFIRM_MESSAGE = "この募集を終了します。よろしいですか?\n15秒以内にこのメッセージに何かリアクションをすると募集を終了します。";
exports.INVITE_CLOSE_CANCELED = "15秒以内にリアクションがなかった為、募集の終了はキャンセルされました。\n終了したい場合はもう一度募集に🚫でリアクションをしてください";
exports.INVITE_CLOSE_FINISHED = "募集を終了しました。";

/** 
 * その他
 */
exports.ZERO_SPACE = "\u200B";
exports.HALF_SPACE = " ";

exports.REACTION_YES = "⭕";
exports.REACTION_MAYBE = "⚠️";
exports.REACTION_NO = "❌";
exports.REACTION_CLOSE = "🚫";

今回増えたリアクションの絵文字関係はマジで外出ししといた方が良いです。
コマンド実行時にリアクションを付与するのと、後々ハンドラで「このリアクションが来てたらこうする」みたいな処理があるので、必ず一致させる目的です。
ハードコードは悪。外出しする癖を付けていきましょう。

config.js

色を増やしました。
もちろん好みによって色は変更してOKです!

module.exports = {
  clientId: process.env.DISCORD_CLIENT_ID,
  token: process.env.DISCORD_TOKEN,
  colors: {
    success: "000080",
    error: "000080",
    invite: "90ee90",
    close: "a9a9a9",
  },
  channels: {
    log: "1069832275002409013",
  },
}

invite.js

今回のキモ1です。
長くなるのでちょっとずつ解説していきます。

ここまで追加したらコマンドとして実行はできるようになるはずなので、一旦テストしてみることをオススメします。

// discord
const Discord = require("discord.js");

// お手製
const app_root = require("app-root-path");
const config = require(app_root + "/config.js");
const string = require(app_root + "/strings/string.js");

module.exports = {
  // スラッシュコマンド登録
  data: new Discord.SlashCommandBuilder()
    .setName(string.INVITE)
    .setDescription(string.INVITE_DESCRIPTION)

    // オプション:募集タイトル
    .addStringOption(option =>
      option.setName(string.INVITE_OPTION_TITLE)
        .setDescription(string.INVITE_OPTION_TITLE_DESCRIPTION)
        .setRequired(true)
    )

    // オプション:募集本文
    .addStringOption(option =>
      option.setName(string.INVITE_OPTION_MESSAGE)
        .setDescription(string.INVITE_OPTION_MESSAGE_DESCRIPTION)
        .setRequired(true)
    )
  ,

  // コマンド処理
  async execute(interaction, client) {
    // コマンド実行ログ
    console.log("/" + string.INVITE);

    // 引数
    const option_title = interaction.options.getString(string.INVITE_OPTION_TITLE);
    const option_description = interaction.options.getString(string.INVITE_OPTION_MESSAGE);

    // Embed
    const embed = new Discord.EmbedBuilder()
      .setTitle(string.EMBED_TITLE_PREFIX_INVITE_NOW + option_title)
      .setDescription(option_description)
      .addFields([
        { name: string.ZERO_SPACE, value: string.ZERO_SPACE },
        { name: string.EMBED_INLINE_INVITE_YES, value: string.ZERO_SPACE, inline: true },
        { name: string.EMBED_INLINE_INVITE_MAYBE, value: string.ZERO_SPACE, inline: true },
        { name: string.EMBED_INLINE_INVITE_NO, value: string.ZERO_SPACE, inline: true },
      ])
      .setColor(config.colors.invite)
      .setTimestamp()
      .setFooter({ text: string.EMBED_FOTTER_INVITE_NOW });

    // 送信
    const sent = await interaction.reply({ embeds: [embed], fetchReply: true });

    // リアクションを付ける
    sent.react(string.REACTION_YES);
    sent.react(string.REACTION_MAYBE);
    sent.react(string.REACTION_NO);
    sent.react(string.REACTION_CLOSE);
  },
}

(15) .addStringOption

コマンド実行時に引数を受け取れるようにする関数です。
名前の通り、addStringOptionは文字列型を受け取ります。

他にもaddIntegerOptionとかaddUserOptionとかaddRoleOptionとかあります。

Parsing options | discord.js Guide

(32) interaction.options.getString

コマンド処理中に引数を取得する関数です。

お察しかもしれませんが、getStringの部分は前述のaddStringOptionの型の部分と紐づいています。
addIntegerOptionの引数はgetIntegerで取得するし、addUserOptionの引数はgetUserで取得します。

(38) .addFields

Embedに更に項目を追加する奴です。

各項目で「inline: true」を渡してあげると、サイズ次第で横並びになります。
でもスマホの場合や中身が横に長い場合は改行される場合もあります。

あとは今更ですが、間を空けたい時はゼロスペースの文字実体参照を入れてあげるのが良いと思います。
nullとか突っ込もうとするとDiscordから怒られます。

(48) fetchReply: true

これを追加することで送信したメッセージに対してあれこれできるようになります。

例えばbotで送信したメッセージにリアクションを付けるとか、送信したメッセージを削除するとかですね。

(50) sent.react

送信したメッセージ(sent)にリアクションをつけます。

一応非同期処理なので、確実にリアクションを付与し終わってから次の処理に行きたい場合はawaitを付けてあげましょう。
待ちたい非同期処理が複数あって並列実行でも良い場合はawait Promise.all([])を使うと一斉にスタートさせられます。(後々invite_handler.jsで登場します)

とはいえ、今回はリアクションを何個か付けて処理は終わりなので、awaitはつけてません。

functions.js

汎用的に使える関数を書くファイルです。

まだ1つしかないですが、一つのコマンド専用じゃない関数はここに追加していくと良いと思います。
Embed用に改行を入れる関数とか、指定分待つ関数とか。

// お手製
const app_root = require("app-root-path");
const string = require(app_root + "/strings/string.js");


/**
 * 指定されたメッセージの指定されたリアクションをしたユーザ一覧を文字列(メンション)で返却する
 * exports: false
 *
 * @param message  interaction
 * @param reaction string
 */
exports.getReactionUserList = async function(message, reaction) {
  try {
    // 指定されたリアクションを付けたユーザ一覧
    const reaction_users = await message.reactions.resolve(reaction).users.fetch();

    let result = string.ZERO_SPACE;
    reaction_users.forEach(function(data) {
      // botは除外する
      if (!data.bot) {
        result += `${data} \n`
      }
    });

    return result;

  } catch (error) {
    // 指定されたリアクションを付けたユーザがいなかった場合はnullを返却する
    console.log(error);
    return null;
  }
}

invite_handler.js

今回のキモ2です。何なら本体。

実行したinviteコマンドに対してリアクションが追加されていくわけですが、追加された時に動くハンドラです。
例によって解説が長くなるので個別に書いていきます。

const Discord = require("discord.js");

// お手製
const app_root = require("app-root-path");
const config = require(app_root + "/config.js");
const string = require(app_root + "/strings/string.js");
const functions = require(app_root + "/functions.js");

/**
 * inviteコマンド リアクション制御
 * exports: true
 *
 * @param message  interaction
 * @param reaction reaction
 * @param user     user
 * @param client   client
 */
exports.call = async function(message, reaction, user, client, is_add) {
  // Embedデータの取得
  const embed = message.embeds[0];

  // 取得したデータの整理
  const title = embed.title.substring(5);
  const description = embed.description;

  // タイトルの5文字が【募集中】と一致するかで募集が終了しているか判断する
  const is_closed = (embed.title.substring(0, 5) == string.EMBED_TITLE_PREFIX_INVITE_NOW) ? false : true;

  // 既に終了している募集
  if (is_closed) {
    // リアクションを削除して処理を停止
    reaction.remove();
    return;
  }

  // 参加表明
  if (reaction.emoji.name === string.REACTION_YES
    || reaction.emoji.name === string.REACTION_MAYBE
    || reaction.emoji.name === string.REACTION_NO) {

    // リアクション付与時は、そのユーザの他のリアクションを削除する
    if(is_add) await removeOtherReactions(message, reaction, user);

    // 参加者一覧
    const user_list = await getInviteReactionUserList(message);
    const yes_list = user_list[0];
    const maybe_list = user_list[1];
    const no_list = user_list[2];

    // 募集更新
    const changed = new Discord.EmbedBuilder()
      .setTitle(string.EMBED_TITLE_PREFIX_INVITE_NOW + title)
      .setDescription(description)
      .addFields([
        { name: string.ZERO_SPACE, value: string.ZERO_SPACE },
        { name: string.EMBED_INLINE_INVITE_YES, value: yes_list, inline: true },
        { name: string.EMBED_INLINE_INVITE_MAYBE, value: maybe_list, inline: true },
        { name: string.EMBED_INLINE_INVITE_NO, value: no_list, inline: true },
      ])
      .setColor(config.colors.invite)
      .setTimestamp()
      .setFooter({ text: string.EMBED_FOTTER_INVITE_NOW });

    message.edit({ embeds: [changed] });
  }
  // 募集〆
  else if (reaction.emoji.name === string.REACTION_CLOSE) {
    // 本人または管理者ではない場合、処理を中断
    const reaction_member = message.guild.members.cache.get(user.id);
    if (!(message.interaction.user === user || reaction_member.permissions.has(Discord.PermissionsBitField.Flags.Administrator))) return;

    const close_confirm_message = await user.send({ content: string.INVITE_CLOSE_CONFIRM_MESSAGE, embeds: [embed] });
    const filter = (confirm_reaction, confirm_user) => confirm_user == user;

    // 参加者一覧
    const user_list = await getInviteReactionUserList(message);
    const yes_list = user_list[0];
    const maybe_list = user_list[1];
    const no_list = user_list[2];

    // 15秒待機
    close_confirm_message.awaitReactions({ filter, max: 1, time: 15 * 1000, errors: ['time'] })
      .then(() => {
        // リアクションが押された場合、募集の終了処理を行う
        const finish = new Discord.EmbedBuilder()
          .setTitle(string.EMBED_TITLE_PREFIX_INVITE_CLOSED + title)
          .setDescription(description)
          .addFields([
            { name: string.ZERO_SPACE, value: string.ZERO_SPACE },
            { name: string.EMBED_INLINE_INVITE_YES, value: yes_list, inline: true },
            { name: string.EMBED_INLINE_INVITE_MAYBE, value: maybe_list, inline: true },
            { name: string.EMBED_INLINE_INVITE_NO, value: no_list, inline: true },
          ])
          .setColor(config.colors.close)
          .setTimestamp()
          .setFooter({ text: string.EMBED_FOOTER_INVITE_CLOSED });

        message.edit({ embeds: [finish] });

        // リアクションを削除する
        message.reactions.removeAll();

        // 終了完了メッセージを送信
        close_confirm_message.delete();
        user.send({ content: string.INVITE_CLOSE_FINISHED });

        return;
      })
      .catch((error) => {
        console.log(error);

        // リアクションが押されなかった場合、確認メッセージを削除する
        message.reactions.resolve(string.REACTION_CLOSE).users.remove(user.id);
        close_confirm_message.delete();
        user.send({ content: string.INVITE_CLOSE_CANCELED });
        return;
      })
  } else {
    // 関係ないリアクションは削除する
    reaction.remove();
    return;
  }
}

/**
 * 指定された募集メッセージにリアクションをしたユーザ一覧を文字列(メンション)で返却する
 * exports: false
 *
 * @param message  interaction
 */
const getInviteReactionUserList = async function(message) {
  // 募集にリアクションを付けたユーザ一覧
  const result = await Promise.all([
    functions.getReactionUserList(message, string.REACTION_YES),
    functions.getReactionUserList(message, string.REACTION_MAYBE),
    functions.getReactionUserList(message, string.REACTION_NO),
  ])

  return result;
}

/**
 * 指定された募集メッセージで最新以外のリアクションを削除する
 * exports: false
 *
 * @param message       interaction
 * @param add_reaction  reaction
 * @param user          Discord user
 */
const removeOtherReactions = async function(message, add_reaction, user) {
  // YES, MAYBE, NOの3種類で確認する
  const reactions = [string.REACTION_YES, string.REACTION_MAYBE, string.REACTION_NO];

  reactions.forEach((reaction) => {
    // 今回付与したリアクション以外のリアクションが削除対象
    if(add_reaction.emoji.name !== reaction) {
      message.reactions.cache.get(reaction).users.remove(user);
    }
  })
}

(18) message.embeds[0];

リアクションされたメッセージのEmbedを取得してます。

取得したEmbedからは色々データを取得してこれます。
今回は募集タイトルと募集説明文ですね。
取得したデータの構造はここ見れば書いてます。あとは自力でconsole.logに出したりとか。

Embeds | discord.js Guide

(23) const is_closed = (embed.title.substring(0, 5)

Embedのタイトルには接頭辞として【募集中】または【終了】を付けることを利用して、現在この募集が募集中かどうかを判断してます。
DBを使ってないので回りくどいことをやっていますが、DBを導入すればこの辺も全部DBで管理でOKです。

(35) await removeOtherReactions

リアクションの追加を制御している場合、同じユーザの他のリアクションを削除する処理です。

実体は135行目の message.reactions.cache.get(reaction).users.remove(user); の部分。
あとはリアクションの種類を配列で持って回しつつ、今回付与されたリアクション以外で消しに行ってます。

(37) await getInviteReactionUserList(message);

現在ついているリアクションを元に、メンションのリストを作る処理です。

取得するユーザのリストは最新の物を使えば良いので、処理時間短縮の為に並列実行してます。
await Promise.all([ ]) の部分ですね。
この関数の中で呼び出した非同期処理が並列実行&全て終わるのを待った上で、代入先に配列として結果を格納しています。

なお、一つ上の await removeOtherReactions の処理は一緒にできません。
Aさんのリアクションはどれか一つにしてからリストを取得したいからです。

これを並列実行してしまうと、Aさんがさっきまで付けてたリアクションでも集計されて、Aさんの名前が2個載っちゃうはず。

(69) close_confirm_message.awaitReactions

終了処理です。
DMで募集の終了を確認するメッセージを送って、リアクションが付与されたら終了処理を行っています。

timeで指定した秒数待機し、リアクションが付与されたら.then()の部分が実行されます。 リアクションが付与されなかった場合や、.then()の処理でエラーがあった際はcatch()が呼ばれます。

inviteコマンドだけなら全然良いのですが、今後色んなコマンドを実装していってDM経由でリアクションを受け取りたいケースが出てきた時はちょっとこの辺りも見直す必要があるかもしれません。
現状はreaction_add_handler.jsでDMのリアクションは無視しているので正常に動いていますが、無視しなくなった場合は動作がおかしくなります。

reaction_add_handler.js

既存ファイルです。
今回追加したinvite_handler.jsを呼ぶ処理を追加しています。

// discord
const Discord = require("discord.js");

// お手製
const app_root = require("app-root-path");
const string = require(app_root + "/strings/string.js");
const invite_handler = require(app_root + "/handlers/invite_handler.js");

exports.call = async function(client) {
  client.on('messageReactionAdd', async (reaction, user) => {
    // botの場合は処理を中断
    if (user.bot) return;

    // DMの場合は処理を中断
    if (reaction.message.guildId == null) return;

    try {
      // 最新の情報を取得
      let channel = reaction.message.guild.channels.cache.get(reaction.message.channel.id);
      let message = await channel.messages.fetch(reaction.message.id);

      // コマンド以外のリアクションは無視する
      if (message.interaction == null || !message.interaction.type === Discord.InteractionType.ApplicationCommand) return;

      // コマンド名 サブコマンド部分を切り取る
      const command_name = message.interaction.commandName.split(string.HALF_SPACE)[0];

      // コマンド別に処理を行う
      switch (command_name) {
        // inviteコマンド
        case string.INVITE:
          console.log("Reaction Handle : invite (add)");
          invite_handler.call(message, reaction, user, client, true);
          break;

        default:
          console.log("Reaction Handle : default (add)")
          return;
      }
    } catch (error) {
      console.error(error);
    }
  });
}

reaction_remove_handler.js

既存ファイルです。
こっちも今回追加したinvite_handler.jsを呼ぶ処理を追加しています。

inviteコマンドではリアクションの削除もハンドリングしたいので追加してますが、削除は検知しなくて良い場合は追加しなくて良いと思います。

// discord
const Discord = require("discord.js");

// お手製
const app_root = require("app-root-path");
const string = require(app_root + "/strings/string.js");
const invite_handler = require(app_root + "/handlers/invite_handler.js");

exports.call = async function(client) {
  client.on('messageReactionRemove', async (reaction, user) => {
    // botの場合は処理を中断
    if (user.bot) return;

    // DMの場合は処理を中断
    if (reaction.message.guildId == null) return;

    try {
      // 最新の情報を取得
      let channel = reaction.message.guild.channels.cache.get(reaction.message.channel.id);
      let message = await channel.messages.fetch(reaction.message.id);

      // コマンド以外のリアクションは無視する
      if (message.interaction == null || !message.interaction.type === Discord.InteractionType.ApplicationCommand) return;

      // コマンド名 サブコマンド部分を切り取る
      const command_name = message.interaction.commandName.split(string.HALF_SPACE)[0];

      // コマンド別に処理を行う
      switch (command_name) {
        // inviteコマンド
        case string.INVITE:
          console.log("Reaction Handle : invite (remove)");
          invite_handler.call(message, reaction, user, client, false);
          break;

        default:
          console.log("Reaction Handle : default (remove)")
          return;
      }
    } catch (error) {
      console.log(error);
    }
  })
}

テスト

コマンドが実装できたら、実際に動かして試してみましょう。
リアクションした人の名前が載ること、他のリアクション押したらそちらに名前が移動すること、募集を終了できること辺りですかね。

今回は正常に動いていない場合にエラーとして出ないケースもあるかと思います。
その場合はconsole.log("test1")みたいな感じで『どこまで処理がいったか』を確認するログを複数埋め込んでみましょう。
慣れてくると動作を見て目星を付けられるようになるかと思いますが、慣れないうちは10ヶ所とかガッツリ書いてもいいかもです。

意図しない挙動になっている行が判明したら、後は使っている変数等を同じくログに出して、意図した内容になっているかチェックしましょう。
変数の名前をtypoしてるとか、自作の関数だと引数の数や順番が違うとか。

プログラムは嘘をつきません、間違うのはいつだって人間。

最後に

無事にinviteコマンドが実装できたら、あとは実際に使ってみましょう!

使っていくと「名前が反映されるのちょっと遅いなー」とか、「bot起動してない時は無反応なんだよな」とか、色々問題点が出てくると思います。

次回は最終回ということで、そういう時にどういうことを考えればいいか、どういう手段があるのかといった話をしていきたいと思います。