用 Claude Code 构建 Discord Bot:discord.js 斜杠命令实战指南
用 discord.js 构建安全的 Discord Bot,完成 /support、/faq、/handoff 和斜杠命令检查。
本地能运行,用户执行时却显示“应用没有响应”
你在开发机上看到 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 用户,并生成包含 bot 和 applications.commands scopes 的邀请链接。不要一开始就给 administrator 权限。这个 Bot 只需要看见支持频道并发送消息。/handoff 则应该只允许有版主权限的人使用,例如具备 Manage Messages 权限的成员。
| 项目 | 值 | 生产注意点 |
|---|---|---|
| Node.js | 本文统一使用 24 LTS | 本地、CI、生产保持一致 |
| OAuth2 scopes | bot, applications.commands | Bot 与斜杠命令都需要 |
| Bot permissions | View Channels, Send Messages | 从最小权限开始 |
DISCORD_TOKEN | Bot token | 不提交、不截图、不写日志 |
DISCORD_CLIENT_ID | Application 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 中加入 type 和 start。
{
"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.json 的 engines 按要求写为 >=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。/handoff 用 Manage 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.0,dotenv固定为17.2.3- Git 忽略
.env,历史记录中没有 token - 邀请链接只包含
bot和applications.commandsscopes - 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 构建 Slack Bot:从问题分流到故障初响应和日报
使用 Bolt JS、Socket Mode、Slash Command、安全配置、测试和上线清单构建 Slack Bot。
用 Claude Code 选择图表库:Recharts、Chart.js 与 D3
用 Claude Code 选择 Recharts、Chart.js 或 D3,构建能处理真实数据的可靠仪表板。
Claude Code 实战 Vue 3 开发:TypeScript、Pinia 与测试
用 Claude Code 改进 Vue 3 项目:表单、Pinia、Composable、Vitest 与既有代码重构的实务流程。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
让 Claude Code 真正进入可验证的工作流
先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。