实战分享多个 Claude Code 长期会话如何通过 Discord 机器人身份相互通讯,包含权限配置、状态管理等工程细节。
我同时维护着几个长期项目,每个项目都有自己的 Claude Code session,也各自积累了不同的上下文。它们使用不同的技术栈、不同的数据库,彼此没有交集。这些 session 的价值恰恰在于它们不共享上下文——但这也意味着,每个 session 都困在自己的终端窗口里,而我只有坐在办公桌前才能与它们交流。
于是,我给每个 session 都分配了一个 Discord bot 身份。现在,即使站在排队的人群中,我也可以向熟悉 .NET 代码库的那个 Agent 询问计费方面的边界情况。
完成这部分只花了一个晚上。接着,我尝试让它们彼此交流,结果耗费的时间多得多——还顺带发现了一个我亲手写进去的安全漏洞。
Claude Code 的 Discord channel plugin 可以让 session 发送和接收 Discord 消息。每个项目运行一个 session,再分别配置独立的 bot token,每个项目就都拥有了一个可单独寻址的身份。
其中有一点不太直观:plugin 会从环境变量中读取其状态目录,因此每个 bot 都需要使用自己的目录。
$env:DISCORD_STATE_DIR = "$env:USERPROFILE\.claude\channels\bot-a"
claude
每个状态目录中都有一个 access.json,用于规定哪些人可以在哪些频道中与这个 bot 交谈,以及是否必须提及它。
{
"dmPolicy": "allowlist",
"allowFrom": ["<my-user-id>"],
"groups": { "<channel-id>": { "requireMention": true, "allowFrom": [] } }
}
如果某个频道没有出现在 groups 中,那么消息会在模型看到之前就被丢弃。当一个 bot 明明已经被邀请进频道,却始终保持沉默时,首先应该检查这里。
当四个 bot 同处一个频道时,显而易见的下一步就是让其中一个负责协调其他 bot。我的协调 bot 发送了一条消息,并直接提及另一个 bot。结果什么都没有发生。没有错误、没有拒绝,也没有任何日志——被提及的 bot 就像什么都没听见一样,继续做自己的事情。
我以为这是权限问题,于是在 Discord developer portal 里折腾了好一阵。但问题并不在那里。下面这个受控对比实验最终确认了原因:
同一个频道、同一份配置、完全相同的提及文本,唯一的区别是消息的发送者。
原因就在 plugin 的 server.ts 中,只有四行代码:
client.on('messageCreate', msg => {
if (msg.author.bot) return
handleInbound(msg).catch(...)
})
Discord 完整无误地传递了 gateway event。真正的问题是,plugin 在访问控制和模型处理之前就把它丢弃了。无论授予什么权限都无法改变这一点,而且也没有相关设置——server 只会读取三个环境变量,其中没有任何一个能影响这里。

这件事值得停下来琢磨一下,因为它揭示了一条调试集成系统时的通用经验:负责传递 event 的层与负责处理 event 的层并不是同一层,而配置只能修复其中某一层的问题。
最显而易见的修复方式是删掉这个 guard。千万别这么做。
这一行代码同时承担着两项职责:它既会拦截其他 bot,也会阻止 bot 对自己的消息作出反应。如果删除它却不提供替代机制,那么只要某个 bot 被允许接收自己的消息,它就会回复自己的回复,无限循环下去,而且循环速度就是 API 的调用速度。
这并非假设。在理解这个限制之前,我曾发送过一条同时提及四个 bot 的消息。六秒之内,其中三个 bot 都作出了回复。如果它们能够听到彼此的消息,这场对话就不会停下来。
因此,替代方案必须将这两个问题分开处理:
// unconditional, not configurable
if (msg.author.id === client.user?.id) return
……至于是否接受其他 bot 的消息,则应该转移到一个能够进行合理控制的位置。
我在 access.json 中添加了一个 allowBots: string[],其默认值为 []——这与原来的行为完全一致,因此没有进行相关配置的 bot 不会受到任何影响。
有两个细节甚至比这个功能本身更加重要:
检查逻辑位于 gate() 中,而不是 event handler 中。gate() 会在收到每条消息时重新读取 access.json,所以即使四个 session 都在运行,也可以直接修改 allowlist,无须重启任何东西。
系统不会进行中心化的强制控制。每个 bot 都通过自己的配置指定哪些身份可以打断它。系统中不存在全局 registry,因此也就不存在某个文件一旦损坏,所有访问限制便会同时失效的问题。
我的第一个设计是在 server 中加入 token-bucket rate limiter,因为四个 Agent 组成的 mesh 会产生平方级增长的循环消息,而我希望能有一道兜底防线。
后来,我改用另一种拓扑结构,大部分问题也随之消失了。

其中一个 Agent 充当 hub。每个 spoke 的 allowBots 中只列出 hub,而 hub 则列出所有 spoke。spoke 无法彼此唤醒。唯一可能形成的循环是 hub ↔ 某个 spoke,而且从系统结构上看,hub 必然位于每一条路径上——因此,只要在 hub 中设置一个 loop-breaker,就能覆盖整个系统。我也不必把避免系统失控的全部希望都寄托在 rate limiter 上。
不过,我还是保留了 server 端的 limiter,作为一种成本很低的保险措施。prompt 层面的规则可能会被模型找到理由绕过,而 server 代码不会。但它不再是不可或缺的关键机制——这正是安全保障与一厢情愿之间的区别。
这里有一个属性值得准确说明:这种隔离针对的是调用,而不是可见性。只要 spoke 当时恰好处于唤醒状态,它仍然可以读取其他 spoke 的消息;它只是无法唤醒其他 spoke。这才是这种设计真正换来的能力,而且它比乍看之下要有限得多。
这是我最希望其他人能够引以为鉴的部分。
plugin 中有一个用于处理权限 prompt 的拦截机制:当某个 tool 需要批准时,你可以直接在 Discord 中回复 y <request_id>,而不必回到终端。附近的代码中有一条注释,大意是:任何通过 gate() 的身份都必然位于 allowFrom 中,所以发送者就是用户本人。
这条假设在代码最初编写时确实成立。但我加入的 allowBots 改动让它不再成立——现在,一个 bot 即使没有出现在 allowFrom 中,也可以通过 gate()。
这意味着某个 Agent 原本可能代替我批准 tool 权限。
要利用这个漏洞,攻击者需要知道五个字符的 request_id,而这个 ID 只会发送到 allowlist 中的 DM,因此这属于 defence in depth 层面的问题,还不算门户大开。修复只需要两行代码:显式检查 allowFrom,并彻底排除由 bot 发送的消息,与 button handler 一直以来的处理方式保持一致。
真正值得关注的是这个错误的形态。单独看这份 patch,它是正确的;但与其他代码结合后,它却是错误的,因为它让一百行之外某条注释中记录的假设失效了。任何 type checker 都无法捕获这种问题。最终发现它的唯一原因,是我在自以为已经完成工作之后,又重新读了一遍文件中的其余代码。
BOM 悄无声息地重置了所有 bot 的配置。在 Windows PowerShell 5.1 中,Set-Content -Encoding utf8 会写入 UTF-8 BOM。JSON.parse 遇到它时会抛出异常,而 server 对无法解析的 JSON 的处理方式,是将文件重命名为 access.json.corrupt-<epoch>,然后从默认配置重新开始——没有 allowlist,也没有频道注册信息。bot 会继续运行,只是不再接收任何消息。十分钟之内,四个 bot 全部中招了。
应该检查字节,而不是配置值,因为除了真正起决定作用的那个 JSON parser,其他 JSON parser 几乎都能容忍 BOM:
Get-ChildItem "$env:USERPROFILE\.claude\channels\*\access.json" |
ForEach-Object { (Get-Content $_.FullName -AsByteStream -TotalCount 3) -join ' ' }
239 187 191 就是 BOM。应该使用 [System.IO.File]::WriteAllText 写入这些文件,因为它在所有 PowerShell 版本中都不会写入 BOM。
fork 出来的 plugin 不在 approved-channels allowlist 中。安装自己修改后的 fork 之后,出站消息仍然完全正常,但入站通知会在 session 看到之前就被丢弃。bot 看起来像是没有响应,而不是出了故障,配置中也看不出任何异常。这个问题的典型特征是:回复可以正常发出,却收不到任何消息。答案藏在 MCP logs 的一行日志中,同时还需要在启动参数里明确指定这个 fork。
Agent 永远不会被告知自己的 user ID。它能看到发送者的 ID,却永远看不到自己的。因此,它无法验证某次提及是否真的是针对自己——当我发送一条消息,同时提及四个 bot 并表示“你是 orchestrator”时,每个 bot 都只能猜测这句话指的是谁。结果三个 bot 都猜错了。其中一个甚至根据频道历史推断自己的身份,随后开始在消息末尾签上另一个 bot 的名字。
这个问题需要用文字解决,而不是代码:分配角色时,要用名称称呼 bot,并明确写出对应的 ID。只提供 ID 没有用,因为接收者无法自行核对。
无论从哪个方向看,沉默的含义都很模糊。Agent 正忙和消息根本没有送达,看起来完全一样。Agent 忽略了你的报告,与它已经执行操作却保持沉默,看起来也完全一样。我在一周之内同时遇到了这两种故障——一条从未送达的消息被误解成拒绝,以及一个 orchestrator 在收到修正后悄悄执行了操作却没有确认。从发送者的角度看,后者与被忽略没有任何区别。
如果你也要构建这样的系统,就应该规定:任何会改变共享状态的操作都必须进行确认。它只需要多发送一条消息,却能消除一整类混乱。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。