Use Cases (更新: 2026/7/21)

用 Claude Code 构建 Discord Bot:discord.js 斜杠命令实战指南

用 discord.js 构建安全的 Discord Bot,完成 /support、/faq、/handoff 和斜杠命令检查。

用 Claude Code 构建 Discord Bot:discord.js 斜杠命令实战指南

本地能运行,用户执行时却显示“应用没有响应”

你在开发机上看到 Bot 成功登录,以为已经完成。真正的用户执行 /support 后,Discord 却提示应用没有响应。用户只好把问题发到聊天频道;版主追问错误信息和紧急程度;换班时又要重新整理上下文。这不是少写了一句回复,而是支持流程没有设计完整。

本文用 Claude Code、discord.js 14.27.0 和三个 slash command 搭出一条可审查的接待流程。/support 收集问题,/faq 返回维护过的短答案,/handoff 只允许版主写交接信息。每个命令先调用 deferReply() 确认收到,再用 editReply() 完成仅用户可见的回复,避免把处理时间押在一次即时响应上。

初学者不必先记住整套 Discord API。可以先这样理解:application command 是 Discord 界面里显示的命令,interaction 是用户执行命令后发给 Bot 的事件。处理顺序固定为“先确认、再校验、再执行、最后完成回复”。

本文会完成什么

  • 使用 Node 24 LTS,并固定依赖版本,不使用 latest
  • 实现 /support/faq/handoff 三个实用命令
  • 开发阶段只注册 guild commands,减少误操作范围
  • 不申请 Message Content intent,也不给 Administrator 权限
  • 保护 token,关闭 mention,并准备可重复的本地检查
flowchart LR
  A["User runs /support"] --> B["Discord interaction"]
  B --> C["discord.js bot"]
  C --> D["Ephemeral user reply"]
  C --> E["Support channel message"]
  E --> F["Moderator handoff"]

第一版故意不接数据库、LLM 和 CRM。先让命令契约、权限边界和失败路径稳定,再讨论扩展。功能少但边界清楚的 Bot,比权限不明、没有回滚方法的炫技演示更适合上线。

用白话理解 application commands 和 interactions

Discord application commands 是 Discord 客户端中原生显示的命令。最常见的是 /support 这样的 slash command。它比旧式 !help 前缀命令更适合支持场景,因为用户在提交前就能看到名称、说明、选项、选择值和权限提示,输入错误会少很多。

Interactions 是用户执行命令、点击按钮、使用选择菜单或提交 modal 时,Discord 发送给应用的事件。使用 discord.js 和 Gateway 时,一般通过 Events.InteractionCreate 处理。Discord 也支持 HTTP endpoint 接收 interactions,但小团队和本地开发阶段,用 Gateway Bot 更容易启动、打日志和排查问题。

规则必须以官方资料为准。命令类型、上下文和注册方式见 Discord Application Commands;延迟响应、followup 和 interaction token 见 Receiving and Responding to Interactions;本文使用的库 API 固定参考 discord.js 14.27.0

权限、环境变量和最小生产架构

在 Developer Portal 创建 Discord application 后,需要添加 Bot 用户,并生成包含 botapplications.commands scopes 的邀请链接。不要一开始就给 administrator 权限。这个 Bot 只需要看见支持频道并发送消息。/handoff 则应该只允许有版主权限的人使用,例如具备 Manage Messages 权限的成员。

项目生产注意点
Node.js本文统一使用 24 LTS本地、CI、生产保持一致
OAuth2 scopesbot, applications.commandsBot 与斜杠命令都需要
Bot permissionsView Channels, Send Messages从最小权限开始
DISCORD_TOKENBot token不提交、不截图、不写日志
DISCORD_CLIENT_IDApplication ID注册命令时使用
DISCORD_GUILD_ID测试服务器 ID开发时使用 guild command
SUPPORT_CHANNEL_ID内部支持频道确认 Bot 能发送消息

给 Claude Code 的提示词可以写成:“用 Node 24 和 discord.js 14.27.0 创建支持 Bot,实现 /support/faq/handoff;固定依赖版本;使用 .env、guild command、最小权限、延迟 ephemeral 回复和 mention 防护;写本地测试,但不要连接 Discord。” 这段约束比“帮我做个 Bot”更能得到可审查的结果。

相关内部阅读可以接 环境变量管理错误处理模式代码审查清单。Bot 虽然小,但这些习惯与更大的 Claude Code 项目完全一致。

可直接运行的 discord.js 示例

下面的示例使用 JavaScript ES modules,不要求先配置 TypeScript。DISCORD_GUILD_ID 存在时会注册到测试服务器,移除后才会注册 global commands。开发时可以保留 DEPLOY_COMMANDS=true,生产常规重启时建议关闭,避免每次启动都覆盖命令。

mkdir discord-support-bot
cd discord-support-bot
npm init -y
npm install --save-exact [email protected] [email protected]
mkdir src

package.json 中加入 typestart

{
  "name": "discord-support-bot",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=18"
  },
  "scripts": {
    "start": "node src/bot.js"
  },
  "dependencies": {
    "discord.js": "14.27.0",
    "dotenv": "17.2.3"
  }
}

创建 .env

DISCORD_TOKEN=replace_with_bot_token
DISCORD_CLIENT_ID=replace_with_application_id
DISCORD_GUILD_ID=replace_with_test_guild_id
SUPPORT_CHANNEL_ID=replace_with_support_channel_id
DEPLOY_COMMANDS=true

创建 src/bot.js

import "dotenv/config";
import {
  Client,
  Events,
  GatewayIntentBits,
  MessageFlags,
  PermissionFlagsBits,
  REST,
  Routes,
  SlashCommandBuilder,
} from "discord.js";

const token = process.env.DISCORD_TOKEN;
const clientId = process.env.DISCORD_CLIENT_ID;
const guildId = process.env.DISCORD_GUILD_ID;
const supportChannelId = process.env.SUPPORT_CHANNEL_ID;

for (const [name, value] of Object.entries({ token, clientId, supportChannelId })) {
  if (!value) throw new Error(`${name} is required.`);
}

const commands = [
  new SlashCommandBuilder()
    .setName("support")
    .setDescription("Send a support request to the team")
    .addStringOption((option) =>
      option
        .setName("summary")
        .setDescription("What happened?")
        .setMaxLength(900)
        .setRequired(true),
    )
    .addStringOption((option) =>
      option
        .setName("severity")
        .setDescription("How urgent is it?")
        .setRequired(true)
        .addChoices(
          { name: "low", value: "low" },
          { name: "normal", value: "normal" },
          { name: "high", value: "high" },
        ),
    )
    .addStringOption((option) =>
      option
        .setName("context")
        .setDescription("Steps, links, or error messages")
        .setMaxLength(1500),
    ),
  new SlashCommandBuilder()
    .setName("faq")
    .setDescription("Show a short answer for a common topic")
    .addStringOption((option) =>
      option
        .setName("topic")
        .setDescription("FAQ topic")
        .setRequired(true)
        .addChoices(
          { name: "setup", value: "setup" },
          { name: "permissions", value: "permissions" },
          { name: "rollout", value: "rollout" },
        ),
    ),
  new SlashCommandBuilder()
    .setName("handoff")
    .setDescription("Create a moderator handoff note")
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
    .addUserOption((option) =>
      option.setName("target").setDescription("User to hand off").setRequired(true),
    )
    .addStringOption((option) =>
      option
        .setName("note")
        .setDescription("What should the next moderator know?")
        .setMaxLength(1500)
        .setRequired(true),
    ),
].map((command) => command.toJSON());

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

client.once(Events.ClientReady, (readyClient) => {
  console.log(`Logged in as ${readyClient.user.tag}`);
});

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

  try {
    if (!interaction.inGuild()) {
      await interaction.reply({
        content: "Please use this command inside the server.",
        flags: MessageFlags.Ephemeral,
      });
      return;
    }

    if (interaction.commandName === "support") await handleSupport(interaction);
    else if (interaction.commandName === "faq") await handleFaq(interaction);
    else if (interaction.commandName === "handoff") await handleHandoff(interaction);
    else await safeReply(interaction, "Unknown command.");
  } catch (error) {
    console.error("Interaction failed:", error);
    await safeReply(interaction, "Something went wrong. Please contact a moderator.");
  }
});

async function handleSupport(interaction) {
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });

  const summary = interaction.options.getString("summary", true);
  const severity = interaction.options.getString("severity", true);
  const context = interaction.options.getString("context") ?? "No extra context.";
  const channel = await fetchSupportChannel();

  await channel.send({
    content: [
      "**New support request**",
      `Reporter: ${interaction.user.tag} (${interaction.user.id})`,
      `Severity: ${severity}`,
      `Channel: <#${interaction.channelId}>`,
      `Summary: ${neutralizeMentions(summary)}`,
      `Context: ${neutralizeMentions(context)}`,
    ].join("\n"),
    allowedMentions: { parse: [] },
  });

  await interaction.editReply("Thanks. Your request was sent to the support team.");
}

async function handleFaq(interaction) {
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });

  const topic = interaction.options.getString("topic", true);
  const answers = {
    setup: "Install Node.js 24 LTS, invite the bot with bot and applications.commands scopes, then run npm start.",
    permissions: "Start with View Channels and Send Messages. Reserve Manage Messages for moderator-only commands.",
    rollout: "Use guild commands for testing. Promote to global commands only after rollback and logging are checked.",
  };

  await interaction.editReply(answers[topic]);
}

async function handleHandoff(interaction) {
  await interaction.deferReply({ flags: MessageFlags.Ephemeral });

  if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
    await interaction.editReply("You need Manage Messages permission to use this command.");
    return;
  }

  const target = interaction.options.getUser("target", true);
  const note = interaction.options.getString("note", true);
  const channel = await fetchSupportChannel();

  await channel.send({
    content: [
      "**Moderator handoff**",
      `Target: ${target.tag} (${target.id})`,
      `From: ${interaction.user.tag} (${interaction.user.id})`,
      `Note: ${neutralizeMentions(note)}`,
    ].join("\n"),
    allowedMentions: { parse: [] },
  });

  await interaction.editReply("Handoff note created.");
}

async function fetchSupportChannel() {
  const channel = await client.channels.fetch(supportChannelId);
  if (!channel || !channel.isTextBased() || typeof channel.send !== "function") {
    throw new Error("SUPPORT_CHANNEL_ID must be a text channel the bot can send to.");
  }
  return channel;
}

function neutralizeMentions(value) {
  return value
    .replaceAll("@everyone", "@ everyone")
    .replaceAll("@here", "@ here")
    .replace(/<@!?(\d+)>/g, "user:$1")
    .replace(/<@&(\d+)>/g, "role:$1");
}

async function safeReply(interaction, content) {
  const payload = { content, flags: MessageFlags.Ephemeral };
  if (interaction.deferred && !interaction.replied) await interaction.editReply({ content });
  else if (interaction.replied) await interaction.followUp(payload);
  else await interaction.reply(payload);
}

async function deployCommands() {
  const rest = new REST({ version: "10" }).setToken(token);
  const route = guildId
    ? Routes.applicationGuildCommands(clientId, guildId)
    : Routes.applicationCommands(clientId);

  await rest.put(route, { body: commands });
  console.log(guildId ? "Guild commands deployed." : "Global commands deployed.");
}

if (process.env.DEPLOY_COMMANDS === "true") {
  await deployCommands();
}

await client.login(token);

先运行 node --version,确认开发环境是 Node 24 LTS。package.jsonengines 按要求写为 >=18,但本文实际开发与部署统一使用 Node 24 LTS,避免本地和生产使用不同运行时。完成下方本地检查前,不需要填写真实 token,也不要执行 npm start 连接 Discord。

三个需要人工判断的 Use case

Bot 的职责是收集、校验和整理信息,不是判断客户是否有权获得支持,也不是替版主决定事件等级。下面三个场景把输入、输出和人的责任拆开说明。

1. 支持请求接待

输入: 成员执行 /support summary:"登录返回 403" severity:high context:"今天发布后开始出现"

Bot 输出: 成员收到仅自己可见的确认;内部支持频道收到报告人、等级、来源频道、摘要和上下文。转发前会中和 mention,用户文字不会触发群体通知。

人的判断: 版主确认能否复现,必要时调整等级,再分配负责人。Bot 不承诺回复时间,也不能仅凭用户选择把问题定性为安全事件。

2. 经过审核的 FAQ

输入: 成员执行 /faq topic:permissions

Bot 输出: 返回一段短答案。真实项目中应链接到唯一的权威页面,不要在代码里复制整份制度。例如权限问题可以继续阅读 Claude Code 权限指南

人的判断: 文档负责人审批答案,并在流程变化时更新。Bot 不临时生成公司政策,也不替团队解释例外情况。

3. 版主交接

输入: 版主执行 /handoff target:@member note:"等待脱敏错误日志,不要索要 token"

Bot 输出: 内部频道收到目标成员、记录人和已做中和处理的备注。命令定义会向普通成员隐藏该命令,handler 仍会再次检查 Manage Messages

人的判断: 下一位版主核对记录、联系成员,并决定关闭还是升级。交接记录只提供上下文,不是审批系统。

导入前后与 ROI 假设

导入前,支持通常从一条自由文本开始。版主再追问错误、环境和紧急程度,交接时手工复制信息。导入后,Discord 先校验命令选项,Bot 按相同顺序把字段送到支持频道。诊断和优先级仍由人决定,但起点变得一致。

不要把这种变化直接写成“必然节省时间”。ROI 必须带假设。假设服务器每月有 80 个请求,其中 40% 因结构化输入少一次追问,每次按 2 分钟计算,那么估算节省为 80 × 0.40 × 2 = 64 分钟/月。如果开发、评审和维护第一版需要六小时,首月 ROI 仍为负。只有请求量、跨社区复用或错误减少能覆盖维护成本时,这个项目才划算。上线前应先记录自己的请求量和追问时间。

Claude Code 与人的责任边界

Claude Code 可以生成项目骨架、命令定义、环境变量校验、本地 fixture、差异审查和回滚清单。也可以让它逐项解释权限用途,检查代码是否意外输出密钥。

人必须创建 Discord application、选择生产频道、批准权限、保存并轮换 token、审核 FAQ 文案,以及决定何时把 guild commands 发布为 global commands。安全事件和客户处置也必须由人负责。真实 token 不能放进提示词、文章、截图或终端记录。

第一版完成后,可以直接使用下面的评审提示词:

把这个 discord.js 14.27.0 Bot 当作安全敏感变更进行审查。
检查每条 interaction 路径是否先 deferReply 再 editReply。
拒绝 Administrator、Message Content intent、latest 依赖、
无限长度输入、可触发 mention 的输出,以及源码或日志中的 token。
按文件和行号列出问题。不要连接 Discord。

Pitfall:上线前要排除的错误

token 出现在 Git 或截图中。 立即按泄露处理:在 Developer Portal 轮换、替换部署密钥、检查日志。删除某一个 commit 不能让旧 token 重新变安全。仓库只提交带占位符的 .env.example,并忽略 .env

为了省事申请 Administrator。 本例只需要支持频道的 View Channel 和 Send Messages。/handoffManage Messages 限制成员;要换权限,必须先写清业务理由。

慢处理导致 interaction 超时。 频道查询、数据库和外部 API 都可能变慢。先执行 deferReply({ flags: MessageFlags.Ephemeral }),完成后再 editReply()。异常处理要兼容已 defer 和已 reply 两种状态。

用户输入触发通知。 @everyone、角色 mention 和编码后的用户 mention 都是不可信输入。发送时保留 allowedMentions: { parse: [] },同时对存储或转发文字做中和处理。

开发环境直接改 global commands。 先注册到一个测试 guild,经过评审的发布步骤再升级为 global。每次进程重启都重新注册,会让变更与回滚难以追踪。

FAQ 变成过期制度。 每条答案指定负责人和复查日期。权威文档改变时,在同一个 pull request 更新 Bot;无法同步维护时只返回链接。

安全与部署检查清单

  • 本地、CI、托管环境都使用 Node 24 LTS
  • discord.js 固定为 14.27.0dotenv 固定为 17.2.3
  • Git 忽略 .env,历史记录中没有 token
  • 邀请链接只包含 botapplications.commands scopes
  • Bot 没有 Administrator 和 Message Content intent
  • 支持频道只开放所需的 View Channel 与 Send Messages
  • /handoff 同时有默认权限和运行时权限检查
  • 三个命令都先私密 defer,再通过 editReply() 完成
  • 转发输入有长度限制,并且无法触发 mention
  • 日志不记录 token 和私密支持内容
  • guild 注册、重启、回滚、token 轮换步骤已有文档
  • global command 发布前由人完成评审

通过清单后,再选择能保持 Gateway 进程、通过环境变量注入密钥并提供可见日志的托管环境。平台名称不如重启方法、日志和 token 负责人重要。需要复用检查表和实现模板的开发者,可以查看 ClaudeCodeLab 产品

实际测试结果

本次只使用本地 fixture 检查。检查内容包括:解析固定版本的 package.json、对 JavaScript 示例做语法检查、确认三个命令定义、验证 mention 中和用例,以及确认源码存在 deferReply()editReply()、最小 intents 和三条指定官方链接。检查过程没有填写真实 token。

本次没有连接真实 Discord application、测试 guild 或 Gateway,也没有实际注册命令、验证频道权限或执行生产部署。因此不能声称 Bot 已服务真实用户、减少追问或节省工时。上线前,负责人仍需在独立测试服务器里,用版主账号和普通成员账号分别执行 /support/faq/handoff

#Claude Code #Discord Bot #discord.js #chatbot #社区
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

让 Claude Code 真正进入可验证的工作流

先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。