在 Flue 中集成 Discord Channel:从签名验收到 Agent 消息下发的完整实战指南
【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue
本文以 Flue 仓库中的 Discord 通道蓝图(blueprints/channel--discord.md)为主线,讲解如何在 Flue 项目中以"应用自有代码"的方式接入 Discord:使用@flue/discord完成带 Ed25519 签名校验的 HTTP Interactions 入站,使用@discordjs/rest完成出站消息下发,并通过dispatch、initialData与defineTool将 Discord 交互绑定到 Flue Agent。读完本文,你将掌握一条从"收到/ask斜杠命令"到"Agent 把回答写回对应频道"的完整可运行链路,并理解其背后的源码级安全边界。
前置认识:Flue 的 Channel 是什么
在 Flue 中,Channel 是一段"应用自有"的入站 HTTP 处理代码:它把外部平台(Discord、Slack、Telegram 等)的 Webhook/交互请求校验、归一化后,通过dispatch转交给 Flue Agent,并同时为 Agent 提供经过授权的出站工具。通用的 Channel 约定定义在 blueprints/channel.md 中,其关键原则包括:
- Channel 只在
app.ts(应用的 Hono 路由表)挂载的位置提供 HTTP 路由; - 出站调用优先选用平台官方 SDK,官方没有时选用社区主导的 REST 客户端;
- 工具是"应用策略":只定义 Agent 实际需要的、窄范围的
defineTool(...),绝不把凭据、任意 API 路径或不受限的目标暴露给模型。
Discord 通道正是这一模式的典型落地:Discord 不提供官方 JavaScript REST SDK,因此 Flue 蓝图明确选用社区维护的@discordjs/rest@^2.6.1,且不引入 Discord Gateway 或长连接 Bot,出站 REST 调用全部走 Fetch 兼容客户端。
第一步:检查项目现状
在写任何代码之前,先按以下顺序勘察目标 Flue 项目:
- 阅读本地指令(如
AGENTS.md)与相关约定; - 识别包管理器与 Flue 目标平台(Node / Cloudflare Workers);
- 按
<root>/.flue/→<root>/src/→<root>/的顺序确定第一个存在的源码根目录; - 检查已有的
agents/、channels/、app.ts(应用路由表)、环境变量类型与密钥约定,确认应用当前支持哪些交互命令。
随后安装依赖:
@flue/discord:Flue 官方的 Discord 入站包(仓库内实现见 packages/discord/src/index.ts);@discordjs/rest@^2.6.1:社区主导的 Discord REST 客户端(Discord 未发布官方 JS REST SDK);valibot:按项目既有依赖约定安装,用于工具输入校验与initialData模式声明。
参考 examples/discord-channel/package.json 可以看到完整依赖集:@discordjs/rest、@flue/discord、@flue/runtime、hono、valibot,以及构建侧的@flue/vite、vite、typescript。
第二步:创建 Channel 模块
在源码根目录下创建<source-dir>/channels/discord.ts,并添加flue-blueprint标记:
// flue-blueprint: channel/discord@1 import { REST } from '@discordjs/rest'; import * as v from 'valibot'; import { createDiscordChannel, type APIInteraction, type APIInteractionResponse, type DiscordDestinationRef, } from '@flue/discord'; import { defineTool, dispatch } from '@flue/runtime'; import { Assistant } from '../agents/assistant.ts'; export const client = new REST({ version: '10' }).setToken(process.env.DISCORD_BOT_TOKEN!); export const channel = createDiscordChannel({ publicKey: process.env.DISCORD_PUBLIC_KEY!, // Path: /channels/discord/interactions async interactions({ interaction }) { if (interaction.type !== 2 || interaction.data.name !== 'ask') { return { type: 4, data: { content: 'Unsupported interaction.', flags: 64 }, } satisfies APIInteractionResponse; } const destination = destinationFromInteraction(interaction); if (!destination || destination.type === 'private') { return { type: 4, data: { content: 'Unsupported interaction.', flags: 64 }, } satisfies APIInteractionResponse; } // The first string option of the `/ask` chat-input command is the prompt. const question = interaction.data.type === 1 ? interaction.data.options?.find((option) => option.type === 3)?.value : undefined; const channelName = interaction.channel?.name ?? undefined; await dispatch(Assistant, { id: channel.instanceId(destination), // Recorded once when this event creates the instance; ignored after. initialData: { channelId: destination.channelId, ...(channelName === undefined ? {} : { channelName }), }, message: { kind: 'signal', type: 'discord.command.ask', body: question ?? JSON.stringify(interaction.data), attributes: { interactionId: interaction.id, commandName: interaction.data.name }, }, }); return { type: 4, data: { content: 'Your request was accepted.', flags: 64 }, } satisfies APIInteractionResponse; }, }); export function postMessage(ref: { channelId: string }) { return defineTool({ name: 'post_discord_message', description: 'Post a message to the Discord destination bound to this agent.', input: v.object({ content: v.pipe(v.string(), v.minLength(1)) }), async run({ data }) { const { content } = data; const result = (await client.post(`/channels/${ref.channelId}/messages`, { body: { content }, })) as { id?: string }; return { output: { ...(result.id === undefined ? {} : { messageId: result.id }) } }; }, }); } function destinationFromInteraction(interaction: APIInteraction): DiscordDestinationRef | undefined { const channelId = interaction.channel?.id ?? interaction.channel_id; if (!channelId) return undefined; if (interaction.guild_id) { return { type: 'guild', guildId: interaction.guild_id, channelId }; } if (interaction.context === 2 || interaction.channel?.type === 3) { return { type: 'private', channelId }; } if (interaction.context === 1 || interaction.channel?.type === 1) { return { type: 'dm', channelId }; } return undefined; }模块内各要素的职责拆解
入站 channel:createDiscordChannel接收publicKey与interactions回调,返回一个带有route()、instanceId()、parseInstanceId()方法的 channel 对象。从 packages/discord/src/index.ts 的源码可以看到,它只声明了一条路由POST /interactions,并校验publicKey必须是 64 位十六进制字符串(^[0-9a-fA-F]{64}$),否则抛出InvalidDiscordInputError。
出站 client:new REST({ version: '10' }).setToken(...)是项目自有的@discordjs/rest实例。注意包根导入(import { REST } from '@discordjs/rest')在 Cloudflare Workers 中会选中其 Fetch 版 Web 导出,因此无需网关即可发起 REST 调用。在 examples/discord-channel/src/channels/discord.ts 中使用了requiredEnv辅助函数,在启动时强制校验DISCORD_BOT_TOKEN存在,比裸的!断言更稳健。
工具 postMessage:这是"应用策略"的典型体现——工具通过闭包捕获ref.channelId,把出站目标绑定在应用代码里,模型只能提交content字段(v.pipe(v.string(), v.minLength(1))),无法指定任意频道或 API 路径,也无法接触 Bot Token。
入站校验的源码级细节
Discord 通道的 HTTP 处理实现在 packages/discord/src/routes.ts,它严格按以下顺序执行安全校验:
- Content-Type 检查:非
application/json直接返回415; - Content-Length 预检:超过
bodyLimit(默认 1 MiB,即1024 * 1024)返回413;非法数字返回400; - 签名与时间戳:解析
x-signature-ed25519(必须为 64 字节 hex)与x-signature-timestamp,当时间戳缺失、非法或与服务器时钟相差超过5 分钟(MAX_SIGNATURE_AGE_SECONDS = 5 * 60)时返回401; - 流式读体并限制大小:通过
ReadableStream逐块读取,累计超过bodyLimit即终止并返回413(源码中还专门处理了 Node 下reader.cancel()的 rejection 以免进程崩溃); - Ed25519 验签:用 Web Crypto
crypto.subtle.importKey('raw', ..., { name: 'Ed25519' }, ...)导入公钥,对timestamp + body的精确字节做crypto.subtle.verify,失败返回401; - PING/PONG 内置处理:
type === 1时直接返回{ type: 1 },无需业务代码介入; - 其余交互原样透传:保留 Discord 的字段名、嵌套结构与数字判别符,不做归一化改写。
这也是蓝图强调"签名必须基于未消费的原始 body 字节"的原因——任何前置的 JSON 解析或改写都会破坏验签。
目的地派生与安全边界
destinationFromInteraction是一个"应用自有"的辅助函数,它从原生字段推导DiscordDestinationRef(类型定义见 packages/discord/src/index.ts):
guild_id存在 →{ type: 'guild', guildId, channelId };context === 2或channel.type === 3→{ type: 'private', channelId }(私密频道);context === 1或channel.type === 1→{ type: 'dm', channelId }(Bot 私信);- 兜底
channel_id(已弃用字段)也可用于推导频道 ID。
这里有两个关键安全事实必须遵守:
- Discord 交互要求提供商响应:即使业务无话可说也不能空手而归,必须返回合法的
APIInteractionResponse,不能依赖"空确认"; - 部分合法交互没有持久目的地,私密频道交互不能作为任意 Bot-Token 消息目标:因此示例在
destination.type === 'private'时直接返回"Unsupported interaction"; - 原生
interaction.token属于短生命周期能力,严禁进入派发消息、工具、模型上下文、日志或持久历史。
channel.instanceId(destination)生成规范化的命名空间实例 ID。从源码看,其格式为discord:v1:guild:<guildId>:<channelId>或discord:v1:(dm|private):<channelId>(ID 均经encodeURIComponent编码),parseInstanceId会反向解析并回验格式。需要强调:实例 ID 标识目的地,但不是授权凭据——直接挂载的 Agent 路由在使用调用方提供的实例 ID 绑定 SDK 操作之前,必须独立完成授权。
initialData 与 attributes 的分工
蓝图对派发数据做了明确分层:
initialData是实例的创建数据:仅在事件首次创建实例时记录一次,之后每次派发都会被忽略。因此 channel 在每次 dispatch 时都传入相同内容。它携带结构化目的地事实(如channelId、channelName),Agent 用useInitialData()读取而不是解析实例 ID,外加少量实例级恒定上下文(如频道名);- 每条消息各自的事实(如
interactionId、commandName)放在 signal 的attributes上,随消息流动。
此外,Flue 的@flue/discord是无状态包,若担心交互重试造成重复处理,可在 dispatch 时指定idempotencyKey: interaction.id使重投收敛到原始提交(见 packages/discord/README.md)。
第三步:在 app.ts 中挂载 Channel
Channel 只有在app.ts显式挂载时才提供 HTTP 路由:
// app.ts import { Hono } from 'hono'; import { channel } from './channels/discord.ts'; const app = new Hono(); app.route('/channels/discord', channel.route()); export default app;要点:
channel.route()是一个纯路由工厂,返回以挂载路径为基准的子应用(Hono 子 app)。源码见 packages/discord/src/index.ts,内部由@flue/runtime的createChannelRouter组装;- 本指南中所有
// Path:注释都假定采用惯例挂载/channels/discord;更换挂载路径会整体平移所有提供商 URL; - 路由后缀必须以
/开头且非空,Discord 交互语义使用/interactions而非/webhook(参见 blueprints/channel.md 的命名约定)。
第四步:编写并挂接 Agent
在<source-dir>/agents/assistant.ts中编写被派发的 Agent:
'use agent'; import { useInitialData, useModel, useTool } from '@flue/runtime'; import * as v from 'valibot'; import { postMessage } from '../channels/discord.ts'; const initialDataSchema = v.object({ channelId: v.string(), channelName: v.optional(v.string()), }); export function Assistant() { useModel('anthropic/claude-haiku-4-5'); const data = useInitialData<v.InferOutput<typeof initialDataSchema>>(); if (!data) throw new Error('This agent is created by the Discord channel dispatch.'); useTool(postMessage(data)); const channelName = data.channelName ? ` #${data.channelName}` : ''; return `Post a concise answer to the bound Discord destination${channelName}.`; } Assistant.initialData = initialDataSchema;几个关键机制:
'use agent'指令必须是模块第一条语句:它负责把 Agent 注册进应用,因此dispatch(...)无需在app.ts中挂载任何路由。只有当 Agent 需要直接通过 HTTP 可达时,才在app.ts中添加app.route('/agents/<name>', createAgentRouter(Assistant))(来自@flue/runtime/routing);Assistant.initialData静态属性:在实例创建时校验派发的initialData;useInitialData()则在每次渲染时返回解析后的值。若 Agent 不是由 Discord channel 派发创建(data为空),直接抛错,保证"只服务绑定目的地";- Channel 与 Agent 的导入环:channel 模块
import { Assistant }、Agent 模块import { postMessage }形成循环引用,之所以安全,是因为双方对导入绑定的读取都发生在延迟回调(interaction 回调)与 Agent 函数体内,而非模块求值阶段。蓝图明确警告:只有在这种"导入绑定只在延迟回调/初始化器内读取"的前提下,循环才是受支持的; - 示例仓库 examples/discord-channel/src/agents/assistant.ts 还展示了进阶做法:用
useAgentFinish钩子检查本次响应是否成功调用了post_discord_message,若没有(模型只输出文本、未调用工具),就追加一条remindersignal 让模型在同一响应内补发——因为对 Discord 而言,工具调用是答案触达用户的唯一通道,模型响应里的纯文本不会自动送达。
第五步:凭据与端到端验证
两个密钥的分工
DISCORD_PUBLIC_KEY:用于校验入站 Ed25519 签名(要求为 64 位十六进制,见validateOptions),对应 Discord 应用交互公钥;DISCORD_BOT_TOKEN:用于出站 REST 调用鉴权(@discordjs/rest的 Bearer Token)。
必须遵循项目既有密钥约定(如 Worker 的 secret binding 或环境变量文档),不得凭空捏造密钥值,也不要把频道 ID、路由或 Bot Token 暴露给模型。
部署后的 Discord 侧配置
部署后,把 Discord 应用的Interactions Endpoint URL配置为完整的公网 HTTPS 交互路由,即app.ts中的挂载路径 + 路由后缀:按惯例挂载/channels/discord时即为/channels/discord/interactions。应用命令(slash commands)的注册同样是"应用自有"职责,只注册本项目实际处理的命令(如/ask)。本地联调 Webhook 需要一个公网 HTTPS 隧道。
本地验证清单
按蓝图要求,在发布前执行:
- 运行项目类型检查(示例仓库为
tsc --noEmit)与目标平台的vite build; - 生成本地 Ed25519 密钥对,构造带签名的 PING 与命令载荷,本地模拟 Discord 请求;
- 覆盖测试:改动过的字节、畸形鉴权(错误签名/过期时间戳/错误 Content-Type)、PING/PONG、
/channels/discord/interactions挂载路由、提供商原生载荷透传、channel-agent 延迟导入环; - 在 Node 与 workerd 两种环境下,用失败即关闭(fail-closed)的假 Fetch 传输真实驱动
@discordjs/rest客户端,验证出站行为——全程不得联系真实 Discord 服务(除非用户明确要求)。
当需要更新既有集成时,应将其与这份完整蓝图逐项比对,应用所有相关变更、保留项目自定义内容,并在实现符合要求后更新主标记文件中的flue-blueprint标记(标记缺失时此比对为强制步骤)。
常见误区与设计原则小结
| 关注点 | 正确做法 | 常见错误 |
|---|---|---|
| 出站 SDK | @discordjs/rest(社区维护、Fetch 兼容) | 引入 Discord Gateway / 长连接 Bot |
| 入站鉴权 | 基于原始 body 字节的 Ed25519 验签 + 5 分钟时间窗 | 先解析 JSON 再验签 |
| 交互响应 | 必须返回合法APIInteractionResponse | 依赖空确认/不响应 |
interaction.token | 禁止进入派发消息、工具、上下文、日志、持久历史 | 当作长期能力存储 |
| 出站工具 | 应用绑定目标,窄范围defineTool | 暴露任意频道 ID 或 Bot Token 给模型 |
| 实例 ID | 仅作目的地标识 | 当作授权凭据 |
| 派发数据 | 结构化事实进initialData,逐条事实进attributes | 让 Agent 解析实例 ID 字符串 |
| 导入环 | 绑定只在延迟回调与 Agent 体内读取 | 在模块顶层读取循环绑定 |
整套模式可概括为:入站靠@flue/discord的严格验签与 PING/PONG 内建处理兜底安全,出站靠项目自有的@discordjs/rest客户端与"应用策略"化的窄工具保持控制,中间用dispatch+initialData+ 实例 ID 把"哪个频道"与"哪个 Agent 实例"干净地绑定起来。完整的可运行参考实现见 examples/discord-channel/README.md 及其src/目录(channels/discord.ts、agents/assistant.ts、app.ts),可直接对照本文逐步落地。
【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考