デイリースクラムの発表順をランダムで決める bot を作る [discord.js + TypeScript]

新型コロナの影響で、ご多分に漏れず弊社の開発メンバーもフルリモートでお仕事をしています。

コアタイム開始の11:00になると Discord に集まりデイリースクラムを行っているのですが、毎回発表の順番を決めるのが面倒なので、参加メンバーをシャッフルしてリストで返す bot を作ってみました。

パッケージ類

Discord コミュニティ謹製のパッケージ discord.js を使いました。

外部パッケージ使う際は型情報が分かっておくと便利ですね。discord.js はそれ自体で型情報を持っているので @types/◯◯ みたいなパッケージを入れる必要はないです。

{
  "name": "discord_sort_gacha",
  "version": "1.0.0",
  "description": "Sort random and list up the online member.",
  "main": "index.js",
  "scripts": {
    "start": "node ./build/index.js",
    "debug": "ts-node ./src/index.ts",
    "compile": "tsc -p .",
    "compile:test": "tsc -p . --noEmit"
  },
  "author": "Shohei-Japan",
  "license": "MIT",
  "dependencies": {
    "discord.js": "^12.5.3",
    "dotenv": "^8.2.0"
  },
  "devDependencies": {
    "@types/node": "^14.14.41",
    "ts-node": "^9.1.1",
    "typescript": "^4.2.4"
  }
}

Bot のアカウント作成

こちらのページに全部書いてあります。

Botアカウント作成- discord.py

このページの通りに行って、Bot アカウント作成、トークン取得、Discord サーバーに招待、までできたら OK です。

テスト送信

さっそくテスト送信してみます。

Discord テキストチャンネルで「おはよう」と送信すると、「やあやあ」と返してくれる処理を書きます。

import { Client, Message } from "discord.js"
const client = new Client()

client.on('message', (message: Message) => {
  if (message.content === 'おはよう') {
    message.channel.send('やあやあ')
  }
})

const token = xxxxxx // 取得した bot のトークン
client.login(token)

結果

f:id:show-hei:20210423045835p:plain うまく通ったので、続きをやっていきましょう。

Discord 用語のおさらい

Discord ではメンバーが集まるためのサーバーを作りますが、discord.js ではこれをギルド (guild) と呼んでます。

ギルドにはチャンネルがあり、テキストチャンネル、ボイスチャンネル、DM チャンネルなどが存在します。

今回の目的としては、デイリースクラムのために「ボイスチャンネルに集まったメンバー」を、「bot にコマンドを送信したテキストチャンネル」にリスト化して返すことですので、テキストチャンネルとボイスチャンネルを活用します。

チャンネルに参加しているメンバー情報は各チャンネル内に含まれているのでそれも使っていきましょう。

実装

これからの処理はこの client.on() の中に記述していきます。 Message 型を参照しているので、message.xxx のデータは補完が効くので楽です。

client.on('message', (message: Message) => {
// bot コメントを受け取ったあとの処理
})

bot コメントしたメンバーのいるボイスチャンネルとメンバー一覧を取得

変数 message 内には、送信されたギルド情報が入っており、ギルド情報にはそのギルド内のチャンネル一覧情報が含まれています。 名前からは連想しづらいのですが、チャンネル情報はチャンネルの cache 内に含まれているようです。ここから bot コメントを送信したメンバーの ID が含まれているボイスチャンネルと、取得したボイスチャンネルに存在するメンバー一覧を取得していきます。

bot コメントは !gacha というテキストとしました。

// コメントしたメンバーを取得
const author = message.author

// コメントを取得
if (message.content === '!gacha') {
  // ギルドに含まれるチャンネルを取得
  const channels = message.guild.channels
  // channels 内の各チャンネルは type を持ち、'text', 'voice' などの値を持つ
  const voiceCH = channels.cache.find((ch: GuildChannel) => {
    if (ch.type !== 'voice') {
      return false
    }
    // ボイスチャンネルのうち、コメントしたメンバーがいるチャンネルを取得
    return !!ch.members.filter((member: GuildMember) => member.user.id === author.id)
  }) as VoiceChannel
    
  if (!voiceCH) {
    console.error('チャンネルがみっかんないよ')
    return
  }
    
  // voice チャンネルに参加しているメンバー一覧を取得
  const voiceCHMemberNames: string[] = voiceCH.members.map((member: GuildMember) => member.user.username)

メンバーをシャッフルする関数

メンバーをシャッフルするための関数を別途用意しました。これを通してあげるといいですね。

const shuffleArray = (array: string[]) => {
  for(let i = (array.length - 1); 0 < i; i--){
    var r = Math.floor(Math.random() * (i + 1))

    var tmp = array[i]
    array[i] = array[r]
    array[r] = tmp
  }
  return array
}

シャッフルしたメンバーをリストにしてチャンネルに返す

あとはシャッフル関数を通して、体裁を整えてチャンネルに返しましょう。

const shuffledMembers = shuffleArray(voiceCHMemberNames)

// リストにしてテキストチャンネルに送信する
const joinedMembers = shuffledMembers
  .map((member: string, index: number) => `${index + 1}. ${member}`)
  .join('\n')

message.channel.send(joinedMembers)

完成

完成したので試してみましょう。

f:id:show-hei:20210423063842p:plain

f:id:show-hei:20210423063904p:plain

ランダムでリスト化することができました!

f:id:show-hei:20210423063944p:plain

channels.cache を参照しているので、その名称から古いキャッシュを参照しちゃう?と不安でしたが、ちゃんとリアルタイムでのメンバーを取ってきてくれました。

index.ts全コード

クリックすると展開されます

// デプロイを想定してるので dotenv 使ってます。
require('dotenv').config()
import {
  Client,
  GuildChannel,
  GuildMember,
  Message,
  User,
  VoiceChannel
} from "discord.js"
const client = new Client()

// アプリケーション名を確認のために表示
client.on('ready', () => {
  if (!client.user) {
    console.error('認証できないよ')
    return
  }
  console.log(`Logged in as ${client.user.tag}.`)
})


// チャット欄のコメントを受け取る処理
client.on('message', (message: Message) => {
  if (!message.guild) {
    console.error('ギルドがないよ')
    return
  }

  // コメントしたメンバーを取得
  const author = message.author

  if (message.content === '!gacha') {
    const channels = message.guild.channels
    const voiceCH = channels.cache.find((ch: GuildChannel) => {
      if (ch.type !== 'voice') {
        return false
      }
      // ボイスチャンネルのうち、コメントしたメンバーがいるチャンネルを取得
      return !!ch.members.filter((member: GuildMember) => member.user.id === author.id)
    }) as VoiceChannel
    
    if (!voiceCH) {
      console.error('チャンネルがみっかんないよ')
      return
    }

    // voice チャンネルに参加しているメンバー一覧を取得
    const voiceCHMemberNames: string[] = voiceCH.members
      .map(member => member.user.username)
    const shuffledMembers = shuffleArray(voiceCHMemberNames)

    // リストにしてテキストチャンネルに送信する
    const joinedMembers = shuffledMembers
      .map((member: string, index: number) => `${index + 1}. ${member}`)
      .join('\n')

    const sendMessage = joinedMembers || 'メンバー取得できなかったよ'

    message.channel.send(sendMessage)
  }
})

/**
 * 与えられた配列をシャッフルして返す
 * @param array 
 * @returns 
 */
const shuffleArray = (array: string[]) => {
  for(let i = (array.length - 1); 0 < i; i--){
    var r = Math.floor(Math.random() * (i + 1))

    var tmp = array[i]
    array[i] = array[r]
    array[r] = tmp
  }
  return array
}

const token = process.env.DISCORD_TOKEN
client.login(token)