如何用 discord.js 建立一個指令框架系統

2022年7月1日

discord.js 指令 框架系統

這篇文章會從 Wolf Yuan 先的 《使用 discord.js 快速建立一個 Discord 骰子機器人》 文章程式碼延伸,教大家簡單建立一個指令系統,讓管理指令更系統性以及方便。如果您是一個 discord.js 新手,並且對此文章要修改(不含撰寫)的程式碼感到陌生者,請先閱讀該文章!

我想先告知你

此文章會提及一些 JS 名詞,會盡量簡單介紹,如果您想要更深入地去瞭解那些東西,以下提供相關連結,我也會在文中的解析提供 MSDN 等其他平台連結讓您參考學習。

前言#

相信有些人在剛入坑 discord.js 的時候,應該有被 discord.js 沒有內附指令系統這件事給嚇到吧?尤其以前有碰過 NextCord 等有附指令系統模組的玩家,到了 discord.js 後,發現沒有指令系統,可能就會被搞到不想入坑了,或者就乾脆只建立一個 messageCreate 監聽器,然後指令處理與指令程式碼全部塞在裡面......

不想入坑的話我是沒什麼意見,畢竟每個人都有自己的選擇。但是只建立一個 messageCreate 監聽器,並且把指令程式碼全部塞在裡頭,我覺得真的會很亂,並且指令一多以後,要改指令時,尋找指令就會變成你維護你機器人時揮之不去的噩夢......

所以想寫此篇文章,讓大家知道一個簡易指令系統要怎麼做,讓機器人的文字指令變得容易維護。

最後有什麼文章寫不好的地方,歡迎提出建議!

先講一下指令構造#

嗯,先來講一下一個指令的構造吧(在此以 ban 指令舉例)!指令構造的名詞在等一下出現喔!

機器龍的精神食糧

Discord 訊息指令 構造

  1. 前綴:幾乎每個 bot 都有的,有點像一個識別,通常能讓人知道自己輸入哪一個機器人的指令
  2. 指令名稱 / 別名:你希望機器人做什麼事
  3. 指令參數:執行指令所需的一些資訊,有些指令會有,也有些會沒有

指令處理分層#

一個最基本指令系統會有以下幾個構造:

  1. 指令載入器:去指令程式碼資料夾讀取指令的程式碼檔案,並將檔案掛載。
  2. 指令暫存區:將掛載進來的指令進行儲存,以利在後續執行指令時,能直接從該儲存空間尋找並執行相對應指令的程式碼。
  3. 指令處理器:接收到訊息後,負責判斷訊息是否為指令,符不符合指令執行條件等,並對訊息進行分析後,去指令儲存區找到對應指令並執行。

指令系統的執行流程#

機器人上線前初始化時,會先將指令載入到程式內,當我們在執行時,會分成以下 4 個步驟:

  1. 指令載入器載入指令 上線後,接收到創建訊息事件
    on("messageCreate", (message) => {
      // 在這裡繼續執行
    });
  2. 判斷、分析指令(分析指令名稱、參數等)
  3. 去指令暫存區尋找是否有相對應的指令
  4. 執行指令(如果指令有找到)

建立指令程式碼資料夾與指令暫存區#

首先,請先在專案資料夾下新增一個資料夾(存放指令程式碼檔案用):

機器龍的精神食糧

Vscode 創建資料夾

創建指令暫存區#

再來,我們來創建指令暫存區吧!
請將載入 discord.js 模組的的大括號內最後面,多加上一個 , Collection 以載入 Collection:

const { Client, Intents, Collection } = require("discord.js");

並請在我們 Ready 事件內加上一行加上初始化我們的指令 Collection:

client.on("ready", () => {
  client.commands = new Collection();
});

我想要你做個筆記

當然在此您也可以使用 Map,只是功能部分,Map 與 discord.js 官方撰寫的 Collection 相比,Collection 功能會比較多,使用起來會比較方便。

Map 簡單來說就是一個儲存很多鍵值對 (key-value) 的物件,跟字典相似,如果想深入了解可參考 MSDN 文件的 Map 介紹

Map 和 Collection 所提供的函數小比較(會列一些個人覺得比較常用的)
函數MapCollection
set()
get()
delete()
equals()
map()
find()
findKey()
filter()
first()

指令載入器#

再來我們寫指令載入器吧!不然撰寫了多少指令,如果沒有載入也是沒辦法使用。

請先在 index.js 同層資料夾下新增 commandLoader.js,在裡面寫入我們載入讀取檔案與資料夾的模組模組:

const { readdirSync } = require("fs");

建立一個函數 loadCommands 用來載入指令,並且匯出這個函數,讓其他檔案也可以使用到:

function loadCommands(client, directory) {}
 
module.exports = loadCommands;

筆記一下

您也可以這樣寫(我也比較習慣這樣寫,但新手推薦上面那種):

module.exports = function loadCommands(client, directory) {};

機器龍的精神食糧

接下來就開始實作載入指令的部分,首先先讀取指令程式碼檔案資料夾:

function loadCommands(client, directory) {
  const files = readdirSync(directory, {
    withFileTypes: true,
  });
}

讀取完後,接著開始對讀取到的檔案或資料夾逐個進行處理:

function loadCommands(client, directory) {
  // 延續上方程式碼
  for (const file of files) {
    if (file.isDirectory()) {
      // 如果讀取到資料夾
      loadCommands(client, `${directory}/${file.name}`); // 進入該資料夾繼續讀取
    } else if (file.name.endsWith(".js")) {
      // 如果是JavaScript檔案
      const cmd = require(`${directory}/${file.name}`); // 掛載指令程式碼檔案
      client.commands.set(cmd.name, cmd); // 將指令存入前面建立的指令暫存區
    }
  }
}

最後,回到 index.js,在 client.login() 前一行加上:

require("./commandLoader.js")(client, "./Command"); // 載入剛剛寫的 loadCommands 並執行

指令處理器#

到此,終於可以撰寫指令處理器,讓指令處理器在接收到訊息後,做進一步的處理了!

先做個小優化:參數切割與判斷小修改#

使用 discord.js 快速建立一個 Discord 骰子機器人 文章中,相信您們已經學到了如何進行指令初步處理,但避免在後續做指令時會有一些問題,還有提高理解性,在這我建議做一些小優化:

client.on("messageCreate", (message) => {
  const prefix = "/"; // 如果要改成自己的前綴,改"裡面的字串
  if (!message.content.startsWith(prefix) || message.author.bot) return; // 修改此行,當發訊息者為機器人時也不執行指令
 
  const args = message.content.slice(prefix.length).trim().split(/ +/g); // 修改此行,修正參數切割
  //...
});
  • message.author.bot:避免機器人互 call,所以加上這個,避免有機器人在發此機器人的指令訊息時,觸發此機器人
  • split(/ +/g)trim():首先,為了避輸入指令的格式不符,參數與參數間空格輸入成兩個以上,造成指令處理時切出空參數,進而影響後續執行問題,改成使用 split(/ +/g),簡單來講意思是以一個或多個連續空格進行切割。/ +/g正則表達式(Regular Expression)trim() 則是去掉整個字串的頭尾空格。

分析參數#

機器龍的精神食糧

先進行指令名稱與參數分離動作。請將剛剛切割參數那行進行修改:

const [command, ...args] = message.content
  .slice(prefix.length)
  .trim()
  .split(/ +/g);

split(/ +/g) 會傳回一個陣列,並將陣列第一項指派給 command,剩下的元素會在被全部收集後,指派給 args 陣列。

...匯集 / 展開運算子 (Spread Operator),在這裡是匯集用,也就是把剩下的陣列元素匯集成陣列。

指令暫存區尋找指令並執行#

請先將指令(例如先前建立的 dice 指令)程式碼先給複製後,貼到其他檔案備用:

備份指令到記事本

再來,請先刪除整個 switch 判斷式,刪除完後會長這樣:

client.on("messageCreate", (message) => {
  const prefix = "/";
  if (!message.content.startsWith(prefix) || message.author.bot) return;
 
  const [command, ...args] = message.content.slice(prefix.length).split(/ +/g);
});

然後再撰寫尋找指令並執行的程式碼:

client.on("messageCreate", (message) => {
  // 延續上方程式碼
  const cmdObject = client.commands.get(command);
 
  cmdObject?.run(message, args, client);
  // 如果有的話就執行指令程式碼,否則 ? 後面將不會被執行
});

機器龍的精神食糧

cmdObject?.run()? 是非強制串接運算子(Optional Chaining),當執行 run function 前,會先確認指令物件有沒有被找到,如果有找到就會執行 run function,否則不執行。也就是說,此行程式碼與下方這段作用相同:

if (cmdObject) {
  // 確認指令物件有沒有找到
  cmdObject.run(message, args, client); // 有的話就執行
}

撰寫指令檔案#

恭喜您!您已經成功完成指令系統,並且可以開始撰寫指令了!

請先在 Command 資料夾下,建立一個 js 檔案(在此以 Dice 指令當範例,故取名 Dice.js): Vscode 創建指令檔案

再來,建立指令:

有用到 discord.js 的東西請記得匯入!(例如:MessageEmbed
const { MessageEmbed } = require("discord.js");
 
module.exports = {
  name: "dice",
  run: async (message, args, bot) => {
    const final = Math.floor(Math.random() * (6 - 1)) + 1;
    const diceEmbed = new MessageEmbed()
      .setTitle(`🎲 你得到了 ${final}`)
      .setColor("#5865F2");
 
    return message.reply({
      embeds: [diceEmbed],
    });
  },
};

最後,啟動機器人並輸入指令!
Discord 機器人 指令執行

機器龍的精神食糧

至此,恭喜您完成了一個簡易的指令系統!若您有問題,可以前往 Yeecord 支援群組 尋求協助! 順帶一提,下面有一些延伸,如果您覺得您在開發上有需要,可以繼續閱讀!

延伸一:怎麼叫,都是叫我!-指令別名#

咦?怎麼有人看似輸入不同的指令名稱,但實際上卻呼叫到同一個指令?
這就是指令別名,也就是一個指令可以用多種指令名稱,讓使用者可以以多個指令名稱呼叫此指令!

首先在 messageCreate 內做一下修改;

client.on("messageCreate", (message) => {
  // 延續上方程式碼
  const cmdObject =
    client.commands.get(command) ||
    client.commands.find((x) => x.aliases?.includes(command));
 
  cmdObject?.run(message, args, client);
});

最後就可以為指令新增別名了(此以 Dice 指令為例):

module.exports = {
  name: "dice",
  aliases: ["doce", "dace"], // <--- 像這樣新增指令別名(當然這裡別名是亂取的)
  run: async (message, args, bot) => {
    const final = Math.floor(Math.random() * (6 - 1)) + 1;
    const diceEmbed = new MessageEmbed()
      .setTitle(`🎲 你得到了 ${final}`)
      .setColor("#5865F2");
 
    return message.reply({
      embeds: [diceEmbed],
    });
  },
};

延伸二:好想偷懶......-檔案名稱直接當指令名稱#

機器龍的精神食糧

有人可能會覺得:每一次建立指令時,指令名稱在指令程式碼檔案名稱和指令物件裡都要輸入,好煩喔~~有沒有辦法減少輸入次數呀?

當然有,在這裡就教大家一個偷懶方法......檔案名稱直接當指令名稱,也就是指令名稱會跟隨著指令程式碼檔案名稱直接變動,如果您覺得指令名稱在指令程式碼檔案名稱和指令物件裡都要輸入很煩,或者你真的很想偷懶的話,可以學起來喔!

我們打開commandLoader.js,修改這行就完成了:

module.exports = function loadCommands(client, directory) {
  //以上略
 
  for (const file of files) {
    if (file.isDirectory()) {
      loadCommands(client, `${directory}/${file.name}`);
    } else if (file.name.endsWith(".js")) {
      const cmd = require(`${directory}/${file.name}`);
      client.commands.set(cmd.name ?? file.name.slice(0, -3), cmd); // <---修改此行
      if (cmd.aliases) {
        for (const aliase of cmd.aliases) {
          client.aliases.set(aliase, cmd.name);
        }
      }
    }
  }
};

然後您就可以刪掉指令物件中的指令名稱(name 屬性),並且把指令程式碼檔案名稱修改成指令名稱了!

機器龍的精神食糧