Discordで通話中のみのチャットを作るBotをdiscord.jsで作った

![discord.js-12.3.1](https://img.shields.io/badge/discord.js-12.3.1-brightgreen) ![request-2.88.2](https://img.shields.io/badge/request-2.88.2-brightgreen) ![Node.js-12.18.3](https://img.shields.io/badge/Node.js-12.18.3-brightgreen)
[Discordで通話中のみのチャットが作りたい！](
https://qiita.com/suzukey/items/8aac0ea3ee829361d5b9)
こちらの記事を見て、社内Discordで使いたいという要望があったので、discord.jsで作ってみました。
処理の流れやソースの構成はほぼそのままです。

# ソースコード
全体は[こちら](https://github.com/shngmsw/SecretChat)にあります。

```javascript:index.js

const Discord = require("discord.js");
const client = new Discord.Client();
const privateChat = require('./main.js')

client.login(process.env.DISCORD_BOT_TOKEN);

// ボイスチャンネルの状態変更イベント時に実行
client.on('voiceStateUpdate', (oldState, newState) => privateChat.onVoiceStateUpdate(oldState, newState));

client.on("ready", () => {
  console.log(`Logged in as ${client.user.tag}!`);
});
```

```javascript:main.js

const Discord = require("discord.js");
const client = new Discord.Client();
const request = require("request");

module.exports = {
  onVoiceStateUpdate: onVoiceStateUpdate
};

const CHANNEL_PREFIX = "🔑";
const BOT_ROLE_NAME = "BOT";

async function onVoiceStateUpdate(oldState, newState) {
  if (oldState.channelID === newState.channelID) {
    return;
  }

  if (oldState.channelID != null) {
    const oldChannel = oldState.guild.channels.cache.get(oldState.channelID);
    if (oldChannel.members.size == 0) {
      await txChDelete(oldChannel);
    } else {
      await chExit(oldChannel, newState.member);
    }
  }

  if (newState.channelID != null) {
    let txtChannel;
    const newChannel = newState.guild.channels.cache.get(newState.channelID);
    if (newChannel.members.size == 1) {
      txtChannel = await txChCreate(newChannel, newState.member);
    } else {
      txtChannel = await chJoin(newChannel, newState.member);
    }
    await chSendNotification(txtChannel, newState.member);
  }
}

// テキストチャンネルを作成
async function txChCreate(voiceChannel, voiceJoinedMember) {
  try {
    const guild = voiceChannel.guild;
    // チャンネル名の後ろにボイスチャンネルのIDを付与して一意に
    let chName = CHANNEL_PREFIX + voiceChannel.name + "_" + voiceChannel.id;
    let botRole = guild.roles.cache.find(val => val.name === BOT_ROLE_NAME);
    let result = await guild.channels.create(chName, {
      parent: voiceChannel.parent,
      type: "text",
      // denyでeveryoneユーザは見れないように
      // allowでボイスチャンネルに参加したメンバーは見れるように
      permissionOverwrites: [
        {
          id: guild.roles.everyone.id,
          deny: ["VIEW_CHANNEL"]
        },
        {
          id: voiceJoinedMember.id,
          allow: ["VIEW_CHANNEL"]
        },
        {
          id: botRole.id,
          allow: ["VIEW_CHANNEL"]
        }
      ],
    });
    return result;
  } catch (err) {
    console.log(err);
  }
}

// ボイスチャンネルのIDがついたテキストチャンネルを検索
function chFind(voiceChannel) {
  const guild = voiceChannel.guild;
  let searchCondition = voiceChannel.id;
  let result = guild.channels.cache.find(val => val.name.endsWith(searchCondition));
  return result;
}

// テキストチャンネルを削除
async function txChDelete(ch) {
  let target = await chFind(ch);
  if (target != null) {
    target.delete().catch(console.error);
  } else {
    console.log("削除するチャンネルがないンゴ");
  }
}

// テキストチャンネルの権限を付与
async function chJoin(ch, user) {
  let target = await chFind(ch);
  if (target != null) {
    target.updateOverwrite(user, { VIEW_CHANNEL: true });
    return target;
  } else {
    console.log("チャンネルがないンゴ");
  }
}

// テキストチャンネルの権限を削除
async function chExit(ch, user) {
  let target = await chFind(ch);
  if (target != null) {
    target.updateOverwrite(user, { VIEW_CHANNEL: false });
  } else {
    console.log("チャンネルがないンゴ");
  }
}

// 入室時にメンションを飛ばして案内
async function chSendNotification(ch, user) {
  const guild = ch.guild;
  const sendChannel = await guild.channels.cache.find(val => val.name === ch.name);
  await sendChannel.send(`<@!${user.id}>`)
    .catch(console.error);

  const embed = new Discord.MessageEmbed()
    .setTitle("プライベートチャットに参加しました。")
    .setAuthor("To " + user.displayName)
    .setDescription(
      "ボイスチャンネルに参加している人だけに見えるチャンネルです。\n全員が退出すると削除されます。"
    );
  sendChannel.send(embed);
}
```

# ハマったところ
同じカテゴリにいくつもチャンネルがあるのでテキストチャンネル名の中にボイスチャンネル名を含めて識別しやすくしたいと考えました。
最初はボイスチャンネルと同じ名前でテキストチャンネルを作成しようとしていましたが、Discordのテキストチャンネルとボイスチャンネルに使える文字種が異なってること(大文字不可、`[]`不可など)を知らなかったため、チャンネル名の不一致によりchFindがうまく動きませんでした。
ボイスチャンネル:`Apex Legends - A`
テキストチャンネル:`apex-legends-a`

結果として参考にした記事と同じようにボイスチャンネルIDを末尾につけて識別できるようにしています。

```javascript
let chName = CHANNEL_PREFIX + voiceChannel.name + "_" + voiceChannel.id;
```

# 最後に
導入以来、社内MTGをDiscordのボイスチャンネルで行う際などに重宝してくれてるようです。
また、discord.pyをdiscord.jsで書き直すのも双方の言語やライブラリ仕様の違いについての理解を深めるいい機会になりました。