用 Claude Code 构建 Discord Bot:discord.js 斜杠命令实战指南
用 Claude Code 和 discord.js 做一个可上线的 Discord 支持 Bot,覆盖权限、环境变量和部署检查。
Discord Bot 应该是支持流程,不只是聊天玩具
Discord Bot 是加入 Discord 服务器并响应用户操作的应用。换成更直白的话,它就是社区里的自动接待员。它可以收集支持请求,返回 FAQ,通知版主,把交接信息整理到内部频道,或者在严重问题扩散前把事件送到正确的人面前。
这比“会聊天的机器人”更实用。真实社区里经常出现这些问题:“我卡住了”“安装文档在哪里”“谁负责这个问题”“这个要不要升级处理”。如果这些内容都发在普通聊天频道里,几分钟后就会被讨论淹没。改成 application commands 之后,用户输入、处理路径、责任人和下一步都会更清楚。
Claude Code 在这里的价值不是替你写一段 client.login(),而是帮你把容易漏掉的生产细节一起补齐:命令注册、环境变量、权限边界、interaction 回复、错误处理、token 安全、部署前检查清单。如果只让它“做一个 Discord Bot”,你可能得到一个演示能跑、上线就脆的项目。
本文用 discord.js 构建一个支持接待 Bot,实现 /support、/faq、/handoff 三个命令。示例避免使用 message content intent,使用斜杠命令,默认把命令注册到测试服务器,并处理 mention 滥用。Node.js 版本按当前 discord.js 文档要求使用 22.12.0 或更新版本。
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 同步或付费状态检查。Claude Code 可以继续扩展这些能力,但第一版应该先让命令、权限和失败路径可靠。
用白话理解 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 更容易启动、打日志和排查问题。
规范必须以官方文档为准。命令类型、命名规则、guild command 与 global command 的差异在 Discord Application Commands。初次响应、followup、interaction token 的行为在 Receiving and Responding to Interactions。discord.js 的安装要求和 API 参考看 discord.js documentation 与 discord.js guide。
权限、环境变量和最小生产架构
在 Developer Portal 创建 Discord application 后,需要添加 Bot 用户,并生成包含 bot 和 applications.commands scopes 的邀请链接。不要一开始就给 administrator 权限。这个 Bot 只需要看见支持频道并发送消息。/handoff 则应该只允许有版主权限的人使用,例如具备 Manage Messages 权限的成员。
| 项目 | 值 | 生产注意点 |
|---|---|---|
| Node.js | 22.12.0 或更新 | 当前 discord.js 文档要求 |
| 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.js 22 的 discord.js 支持 Bot,包含 /support、/faq、/handoff。使用 .env,开发阶段注册 guild commands,最小权限,用户回复使用 ephemeral,并生成部署检查清单。” 这样它更可能生成接近真实项目的结构,而不是泛泛的聊天机器人。
相关内部阅读可以接 环境变量管理、错误处理模式 和 代码审查清单。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 discord.js dotenv
mkdir src
在 package.json 中加入 type 和 start。
{
"type": "module",
"scripts": {
"start": "node src/bot.js"
},
"dependencies": {
"discord.js": "latest",
"dotenv": "latest"
}
}
创建 .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) {
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.reply({
content: "Thanks. Your request was sent to the support team.",
flags: MessageFlags.Ephemeral,
});
}
async function handleFaq(interaction) {
const topic = interaction.options.getString("topic", true);
const answers = {
setup: "Install Node.js 22.12+, 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.reply({
content: answers[topic],
flags: MessageFlags.Ephemeral,
});
}
async function handleHandoff(interaction) {
if (!interaction.memberPermissions?.has(PermissionFlagsBits.ManageMessages)) {
await interaction.reply({
content: "You need Manage Messages permission to use this command.",
flags: MessageFlags.Ephemeral,
});
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.reply({
content: "Handoff note created.",
flags: MessageFlags.Ephemeral,
});
}
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.replied || interaction.deferred) 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,确认版本不低于 22.12.0,再执行 npm start。先在测试服务器运行 /support,确认用户只看到 ephemeral 回复,而内部支持频道收到结构化消息。然后测试 /faq。最后分别用版主账号和普通账号测试 /handoff,确认权限不是摆设。
Use case: 三个真正值得保留的场景
第一个 Use case 是支持接待。好的接待命令只问 summary、severity、context 三项,就能完成基本 triage。triage 可以理解为“判断下一步该忽略、回答、升级、复现还是分配”。在小型测试服务器中,这种结构比自由发言更容易留下错误信息、紧急程度和复现线索,版主第一轮回复不再只是“请补充环境”。
第二个 Use case 是 FAQ 分流。不要让 Bot 在聊天里贴一整篇文档。更好的做法是给短答案,再给正确链接。安装问题可以指向 Claude Code 入门指南,CLI 问题可以接 CLI 工具开发,团队规则可以接 CLAUDE.md 模板。这样 Bot 是站点导航的一部分,而不是第二套失控文档。
第三个 Use case 是版主交接。付费社区、训练营、游戏服务器和产品支持都会遇到轮班。如果当前负责人只在频道里写“下一个人看一下”,接手的人很难知道目标用户、上下文和下一步。/handoff 让这些信息固定成一条内部消息,减少误会。
第四个场景是培训支持。Claude Code 训练营里,很多学员会遇到相同的 Node 版本、环境变量或命令错误。Bot 先收集版本、执行命令和最后几行错误,讲师就能从诊断开始,而不是反复问基础信息。要把这套流程放进团队服务器,可以从 培训与咨询 开始。
Pitfall: 上线前必须排掉的坑
第一个 Pitfall 是泄露 Bot token。token 出现在 Git 历史、截图、CI 日志或文章里,都要当作已经泄露处理。立刻在 Developer Portal 轮换 token,并删除旧值。提示 Claude Code 时要明确写:只创建 .env.example 占位文件,不打印真实 secret,不把 token 写入文档。
第二个坑是在开发阶段频繁注册 global commands。guild commands 只影响测试服务器,反馈快,风险低。global commands 是面向生产的入口,应该在命令名、描述、权限和回滚步骤都确认后再发布。不要让生产环境每次重启都自动覆盖命令,除非这是有审查的部署步骤。
第三个坑是某些 interaction 分支没有回复。用户会以为命令坏了。成功、失败、未知命令、异常捕获都必须 reply、defer 或 follow up。如果以后加入外部 API 或 LLM 摘要,先 defer,再补结果。本文代码工作量很短,所以可以直接回复。
第四个坑是 mention 滥用。用户输入如果原样转发到内部频道,@everyone、@here、用户 mention 和角色 mention 都可能造成通知事故。代码同时使用 allowedMentions: { parse: [] } 和字符串清理,这是公开服务器应该保留的防线。
第五个坑是为了省事给 Bot administrator 权限。它会掩盖权限设计问题,也会在 token 被盗时扩大损失。先用 View Channels 和 Send Messages,只有在代码审查中能解释清楚时才增加权限。
安全与部署检查清单
- Developer Portal 中有人负责轮换 Bot token
.env不进 Git,.env.example只写占位值- 邀请链接从
bot与applications.commandsscopes 开始 - Bot permissions 以 View Channels 和 Send Messages 为基础
/handoff只允许版主级成员使用- 先测试 guild commands,再发布 global commands
- 所有 interaction 分支都有 reply、defer 或 follow up
- 用户输入已防止 mention 滥用
- 日志不包含 token、支付信息、邮箱或私人支持内容
- 部署目标使用 Node.js 22.12.0 或更新
- 上线前写好重启和回滚步骤
小型 Bot 可以部署到 Railway、Render、Fly.io、VPS 或内部服务器。选择平台前先确认三件事:secret 能和代码分离,日志能查看,重启和回滚步骤能写下来。让 Claude Code 生成 README 时,也要明确要求包含这些部分。
文章不应该只停在代码。个人开发者需要可复用模板时,可以看 ClaudeCodeLab 产品。团队要把 Discord 支持、Claude Code 权限、代码审查和训练流程做成制度,可以看 培训与咨询。如果要把 Bot 当作生产功能维护,也建议阅读 代码审查自动化。
实际测试后的结果
把这个 starter 放到小型测试服务器后,最大的收益不是 Bot 变得更聪明,而是请求形状变清楚了。自由发言经常只有“运行不了”,而 /support 会强制留下 summary、severity 和 context,版主第一次回复更接近诊断。真正容易出事故的是 token、global command、mention 和权限。Claude Code 最有用的用法,是让它在写代码的同时生成 .env.example、权限表、失败回复和部署前检查清单。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。