每次都要找些好看的封面圖還挺麻煩的,就當是美圖分享吧

最近假期在家真的閒到發慌。家裡管得嚴,加上妹妹正在準備升學考試,為了不吵到她,我在家也不太好意思一直打遊戲。結果呢,唯一的娛樂活動反而變成了寫程式~~(蛤~~

剛好,把我這個小破站翻新完之後,突然覺得有點空虛,沒有什麼別的事要做了。於是,在發呆了一下午之後還是決定:不如再來試著重新寫一次 Discord 音樂機器人好了。

寫音樂機器人這件事我在中學時就做過一次了。當時花了我好幾個月的時間,寫了一個能24/7在線的音樂機器人。至今還記得音樂開始播放的時候有多興奮。所以這一次不是簡單的做一個會播歌機器人就好,那也太無聊了。我想要來寫點好玩的:給我的機器人加一個可以互動的網頁介面。

請注意:這篇不是那種按部就班的「保姆級教學文」喔...我只想在這裡紀錄開發歷程,並且盡我所能解釋其背後的工作原理。想找教程可以出門左轉去谷歌上找(x

1. 介紹

如果你在 2026 年還想弄一個能在 Discord 語音頻道裡播歌的機器人,查完資料你就會發現,這是一件苦差事。

自從 Groovy 和 Rythm 在 2021 年被 YouTube 官方強制下線後,開發音樂機器人就成了一場開發者與YouTube 的軍備競賽。以前方便好用的 node-ytdl-core在2024年就掛掉了,而其他的替代品 :play-dlytdl-core也紛紛停止開發了。

想用以前的老套路,硬找個還活著的 package 來下載 YouTube 音源再塞給 Discord,你會遇到一堆問題:

  • 極度脆弱: YouTube 現在會主動打擊爬蟲。他們會頻繁更改客戶端、加密演算法,甚至對資料中心的 IP 進行嚴格的限流。這意味著機器人可能昨天還好好的,今天早上起來就滿地報錯。
  • 效能問題: Node.js 是一個單執行緒(Single-threaded)的執行環境,不是設計來做繁重媒體處理的。讓它一邊下載音訊、解碼,還要重新編碼並即時推送到 Discord,只要同時服務幾個不同的伺服器,CPU 就要撐不住開始哀嚎了。
  • 職責過載: 機器人要負責監聽 Discord 訊息事件、爬取 YouTube、處理音軌,還要維持語音頻道的串流。只要其中一個環節崩潰,整個機器人就會直接斷線,聽歌聽到一半的朋友大概會氣死。

如果 Node.js 處理音訊這麼痛苦,那答案很簡單:不要讓你的機器人碰音訊。

解決這個問題的其中一個方法就是使用 Lavalink。它是一個專門為 Discord 機器人打造的獨立 Java 音訊伺服器。機器人只負責接收使用者的指令(例如 /play),Lavalink 則扛下所有髒活:解析 YouTube 網址、繞過限制、解碼音訊,並直接把串流送到 Discord 的語音頻道裡。兩者之間只透過 WebSocket 和 REST API 進行溝通。

這樣一來,如果機器人因為某些 Bug 當機重啟了,Lavalink 依然會在背景繼續播歌,完全不影響正在聽音樂的使用者。

3. 代碼結構

所以說了這麼多,機器人應該長什麼樣?

我這裡選用的是 Deno + Svelte + Lavalink 的組合。整個專案包含了三個伺服器,資料夾結構大概長這樣:

Music-bot/
├── deno.json                  # 配置文件
├── .gitignore
├── bot-backend/               # 機器人本體
│   ├── main.ts                
│   ├── deploy-commands.ts     
│   └── .env
├── lavalink-engine/           # Lavalink
│   ├── Lavalink.jar           
│   ├── application.yml        
│   └── plugins/               
└── web-dashboard/             # SvelteKit 網頁前端

這個專案裡包含了三個伺服器:網頁後端,Lavalink伺服器和機器人。

運行邏輯大約是這樣的:

1.00

機器人代碼意外的非常簡單。只需要用到 Shoukaku(負責跟 Lavalink 溝通)和 discord.js(負責跟 Discord 溝通),就可以開始動工了。

一個最基本~~(陽春)~~的機器人代碼(只有/play/stop指令):

import {
  Client,
  GatewayIntentBits,
  type ChatInputCommandInteraction,
} from "discord.js";
import { Shoukaku, Connectors, LoadType } from "shoukaku";
import "@std/dotenv/load";

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildVoiceStates,
  ],
});

// Shoukaku 初始化

const nodes = [
  {
    name: "main",
    url: `${Deno.env.get("LAVALINK_HOST")}:${Deno.env.get("LAVALINK_PORT")}`,
    auth: Deno.env.get("LAVALINK_PASSWORD")!,
  },
];

const shoukaku = new Shoukaku(new Connectors.DiscordJS(client), nodes, {
  reconnectTries: 10,
  reconnectInterval: 3000,
});

shoukaku.on("error", (_, error) => console.error("Shoukaku error:", error));
shoukaku.on("ready", (name) => console.log(`Lavalink node "${name}" connected`));
shoukaku.on("disconnect", (name, reason) =>
  console.warn(`Lavalink node "${name}" disconnected — ${reason ?? "unknown reason"}. Reconnecting...`)
);

// ====================

client.once("clientReady", () => {
  console.log(`Logged in as ${client.user?.tag}`);
});

client.on("interactionCreate", async (interaction) => {
  if (!interaction.isChatInputCommand()) return;

  switch (interaction.commandName) {
    case "play":
      await handlePlay(interaction);
      break;
    case "stop":
      await handleStop(interaction);
      break;
  }
});

async function handlePlay(interaction: ChatInputCommandInteraction) {
  const member = await interaction.guild?.members.fetch(interaction.user.id);
  const voiceChannel = member?.voice.channel;

  if (!voiceChannel) {
    await interaction.reply({
      content: "需要加入語音頻道才能播放音樂。",
      ephemeral: true,
    });
    return;
  }

  await interaction.deferReply();

  const query = interaction.options.getString("query", true);
  const node = shoukaku.options.nodeResolver(shoukaku.nodes);

  if (!node) {
    await interaction.editReply("找不到Lavalink節點。");
    return;
  }

  const isUrl = query.startsWith("http://") || query.startsWith("https://");
  const result = await node.rest.resolve(isUrl ? query : `ytsearch:${query}`);

  if (!result || result.loadType === LoadType.EMPTY || result.loadType === LoadType.ERROR) {
    await interaction.editReply("找不到任何結果。");
    return;
  }

  const track = result.loadType === LoadType.SEARCH || result.loadType === LoadType.PLAYLIST
    ? result.data[0]
    : result.data;

  let player = shoukaku.players.get(interaction.guildId!);
  if (!player) {
    player = await shoukaku.joinVoiceChannel({
      guildId: interaction.guildId!,
      channelId: voiceChannel.id,
      shardId: 0,
    });
  }

  await player.playTrack({ track: { encoded: track.encoded } });

  await interaction.editReply(`Now playing: **${track.info.title}**`);
}

async function handleStop(interaction: ChatInputCommandInteraction) {
  const player = shoukaku.players.get(interaction.guildId!);

  if (!player) {
    await interaction.reply({
      content: "目前沒有播放音樂。",
      ephemeral: true,
    });
    return;
  }

  await shoukaku.leaveVoiceChannel(interaction.guildId!);
  await interaction.reply("停止播放並退出語音頻道。");
}

client.login(Deno.env.get("DISCORD_TOKEN"));

如你所見,複雜的處理音頻 / 連接語音頻道代碼都被外包給了 Shoukaku 和 Lavalink。整個機器人的代碼非常簡潔,只有一百多行。當然,這仍不包含更複雜的其他音樂控制指令。但是,有了這個基本框架,想要繼續擴展控制指令就很方便了。

而關於網頁控制頁面,目前我也還沒有開發完成前端,也不知道具體會長什麼樣。不如就把它留到下一篇文章再聊吧(挖坑

感謝你看到這裡!趁著假期我想多用部落格練練手,多寫點文章紀錄學習過程,順便練習一下如何把問題給講解清楚。如果你有任何問題,或者是文中有出錯的地方,都歡迎留言告訴我!

留言區