從零開始的Discord機器人(3) - 改善指令架構

本篇會將改善目前Discord機器人的指令架構,使其更易於擴充與維護。

  在上一篇中,我們幫機器人加上斜線指令,目前機器人已經具有接收指令與回覆的能力了。但我相信你一定不滿足於現狀,打算幫機器人加上更多指令。

  然而很快地你發現了問題:一開始加上一兩個指令,好像沒遇到什麼問題,但隨著指令越加越多,漸漸地整個機器人的程式碼變得又臭又長,不僅不利於維護,同時也難以擴充。因此在本文中,我們將會改善機器人的指令架構,讓機器人本體與指令分開,以利於擴充指令。

1. 從指令本體開始

  首先,我們先來處理指令本體的部分。

  到目前為止,我們新增指令的方式都是全部寫在index.js裡。如果只有少少幾個指令的話還行,當指令越來越多時便會難以閱讀與維護。所以接下來我們要把指令都拆分成各自的檔案。

  新增一個commands資料夾,這個資料夾會用來存放所有斜線指令的檔案。目前我們有ping與hello兩個指令,因此我們在commands內新增ping.js與hello.js兩個檔案。

  接下來以ping.js為例,我們要使用module.exports來匯出所有需要的東西,包含SlashCommandBuilder以及接收interactionCreate的事件要執行的動作,完整的檔案會長這樣:

const { SlashCommandBuilder } = require ('discord.js')

module.exports = {
    data: new SlashCommandBuilder()
    .setName ('ping')
    .setDescription('Replies with "Pong!"')
    .setDefaultMemberPermissions('0'),

    async execute (interaction) {
        interaction.reply('pong!')
    }
}

  之後要新增指令時,只要依照和上面SlashCommandBuilder與execute的結構就可以輕易擴充了。讓我們也完成hello.js的部分吧:

const { SlashCommandBuilder } = require ('discord.js')

module.exports = {
    data: new SlashCommandBuilder()
    .setName ('hello')
    .setDescription('Say hello to someone"')
    .setDefaultMemberPermissions('0')
    .addUserOption(option =>
        option
            .setName('user')
            .setDescription('The user to say hi to')
            .setRequired(false)
    ),

    async execute (interaction) {
        let user = interaction.options.getUser('user')
        interaction.reply(`Hello <@${user.id}>!`)
    }
}

2. 註冊指令也要聰明一點

  除了指令之外,我們每新增一個指令時,都會使用client.application.commands.create來註冊指令,但隨著後續指令的增加,每次新增指令都要加上一條註冊指令顯然不是一個好做法。

  我們先來做一點事前準備。由於這次的目標是使用在單一伺服器的機器人,因此我們直接把伺服器的頻道ID寫在config.js內。另外,我們還需要到Discord Developer Portal複製機器人的General Information內的Application ID,這兩個值都是我們之後會用到的。現在的config.js應該會長這樣:

{
    "token": "填入機器人的Token",
    "guildId": "填入伺服器的ID",
    "applicationId": "填入剛剛取得的Application ID",
}

  接下來,讓我們新增一個檔案deploy-commands.js,首先是require:

const fs = require('node:fs')
const path = require('node:path')
const { REST } = require("@discordjs/rest")
const { Routes } = require("discord.js")
const {token, applicationId, guildId} = require('./config.json')

  再來,我們要新增一個function來幫助我們取得檔案:

function getFiles (dir) {
    const files = fs.readdirSync (dir, {
        withFileTypes: true
    })
    let commandFiles = [];

    for (const file of files) {
        if (file.isDirectory()) {
            commandFiles = [
                ...commandFiles,
                ...getFiles(`${dir}/${file.name}`)
            ]
        }
        else if (file.name.endsWith(".js")) {
            commandFiles.push (`${dir}/${file.name}`)
        }
    }
    return commandFiles
}

  這個getFiles的功能是,指定一個資料夾路徑,function會幫我們把指定路徑內所有的JS檔都找出來(包含所有子目錄)。我們接下來要做的事情是,用getFiles來取得commands資料夾內的所有指令並一一註冊。首先是取得指令的資料:

let commands = [];
const commandFiles = getFiles ('./commands');

for (const file of commandFiles) {
    const command = require (file);
    commands.push (command.data.toJSON ());
}

  有了指令的資料,我們就可以開始註冊指令了:

const rest = new REST ({version: '10'}).setToken (token)

rest.put (Routes.applicationGuildCommands (applicationId, guildId), { body: commands })
    .then (() => console.log ('Successfully registered application commands!'))
    .catch (console.error)

  如果你想要讓所有伺服器都能使用指令,只要將Routes.applicationGuildCommands改成Routes.applicationCommands,如此一來就不需要用到guildId。之後當我們新增指令之後,只要執行一次node deploy-commands.js,就能一次註冊所有指令了。

3. 執行的部分也要改寫

  接下來,我們也來改寫一下InteractionCreate的部分,目前應該是長這樣:

client.on(Events.InteractionCreate, interaction => {
    if (!interaction.isChatInputCommand ()) return;
    if (interaction.commandName === "ping") {
        interaction.reply ("Pong!");
    }
    if (interaction.commandName === "hello") {
        const user = interaction.options.getUser ('user');
        interaction.reply (`Hello <@${user.id}>`);
    }
});

  在這裡,我們會根據interaction的commandName來做相對應的動作,但因為我們在上面已經把每個指令要執行的動作都放在各自的execute裡面了,所以我們可以改寫成根據commandName找到相對應的指令並呼叫execute。在這裡我們要使用Discord.js內的Collection實現這一功能。

  Collection其實就是JavaScript內建的Map物件的擴充。簡短說明一下,Map是一種鍵值對(key-value pairs)的物件,也就是Map內每一個key都有其對應的value。所以我們可以利用Collection(或者說Map)這種特性,當我們以commandName為key時,就能得到相對應的指令了。

  首先我們先來建立一個可以生成我們需要的Collection的function吧:

function getCommands (dir) {
    let commands = new Collection ();
    const commandFiles = getFiles (dir);
    
    for (const commandFile of commandFiles) {
        const command = require (commandFile);
        commands.set (command.data.toJSON ().name, command);
    }
    return commands;
}

  在這裡也有使用到getFiles,你可以將getFiles與getCommands都整理到一個額外的JS檔內,之後在其他JS檔內只要require就能重複使用,但礙於篇幅關係這裡就不特別整理了。

  有了getCommands之後就能生成Collection了,我們將它放在client.commands內:

client.commands = getCommands ('./commands');

  接下來是改寫InteractionCreate的部分,首先我們根據interaction.commandNameclient.commands內得到相應的command並呼叫其execute就完成了。改寫之後會長這樣:

client.on(Events.InteractionCreate, interaction => {
    let reaction;
    if (interaction.isChatInputCommand()) {
        reaction = client.commands.get(interaction.commandName);
    }

    try {
        if (interaction.replied) return
        reaction.execute(interaction);
    }
    catch (error) {
        console.error(error);
    }
});

  這裡有順便做一些小調整,之前的寫法中,如果取得的interaction不是斜線指令就會直接結束。調整後,如果是斜線指令就會取得相應的reaction。這是為了後續能夠方便擴充,之後也能根據interaction的類型不同(Button、Modal等),到不同的Collection內取得相應的資料並執行。

  最後,你可以開始運作你的機器人了。

4. 接下來要做的事情

  到目前為止,我們改善了機器人的架構,後續如果需要新增或管理斜線指令,都只會在commands資料夾的範圍內操作,不會影響到機器人本體。但接下來還有可以改善的地方,也許你有發現,機器人可以監聽的事件其實有很多,並不只有ClientReady與InteractionCreate,只要是使用者的操作基本上機器人都能監聽。

  因此,接下來我們也會像拆分指令那樣,將監聽事件從機器人本體分離。如此一來,整理機器人架構就算完成了。

  感謝你看到這邊,我們下次見。