
在 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/discordFlue 官方的 Discord 入站包仓库内实现见 packages/discord/src/index.tsdiscordjs/rest^2.6.1社区主导的 Discord REST 客户端Discord 未发布官方 JS REST SDKvalibot按项目既有依赖约定安装用于工具输入校验与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/discord1 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; }模块内各要素的职责拆解入站 channelcreateDiscordChannel接收publicKey与interactions回调返回一个带有route()、instanceId()、parseInstanceId()方法的 channel 对象。从 packages/discord/src/index.ts 的源码可以看到它只声明了一条路由POST /interactions并校验publicKey必须是 64 位十六进制字符串^[0-9a-fA-F]{64}$否则抛出InvalidDiscordInputError。出站 clientnew 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直接返回415Content-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 Cryptocrypto.subtle.importKey(raw, ..., { name: Ed25519 }, ...)导入公钥对timestamp body的精确字节做crypto.subtle.verify失败返回401PING/PONG 内置处理type 1时直接返回{ type: 1 }无需业务代码介入其余交互原样透传保留 Discord 的字段名、嵌套结构与数字判别符不做归一化改写。这也是蓝图强调签名必须基于未消费的原始 body 字节的原因——任何前置的 JSON 解析或改写都会破坏验签。目的地派生与安全边界destinationFromInteraction是一个应用自有的辅助函数它从原生字段推导DiscordDestinationRef类型定义见 packages/discord/src/index.tsguild_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):channelIdID 均经encodeURIComponent编码parseInstanceId会反向解析并回验格式。需要强调实例 ID 标识目的地但不是授权凭据——直接挂载的 Agent 路由在使用调用方提供的实例 ID 绑定 SDK 操作之前必须独立完成授权。initialData 与 attributes 的分工蓝图对派发数据做了明确分层initialData是实例的创建数据仅在事件首次创建实例时记录一次之后每次派发都会被忽略。因此 channel 在每次 dispatch 时都传入相同内容。它携带结构化目的地事实如channelId、channelNameAgent 用useInitialData()读取而不是解析实例 ID外加少量实例级恒定上下文如频道名每条消息各自的事实如interactionId、commandName放在 signal 的attributes上随消息流动。此外Flue 的flue/discord是无状态包若担心交互重试造成重复处理可在 dispatch 时指定idempotencyKey: interaction.id使重投收敛到原始提交见 packages/discord/README.md。第三步在 app.ts 中挂载 ChannelChannel 只有在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中编写被派发的 Agentuse 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 useInitialDatav.InferOutputtypeof 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/routingAssistant.initialData静态属性在实例创建时校验派发的initialDatauseInitialData()则在每次渲染时返回解析后的值。若 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标记标记缺失时此比对为强制步骤。常见误区与设计原则小结关注点正确做法常见错误出站 SDKdiscordjs/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客户端与应用策略化的窄工具保持控制中间用dispatchinitialData 实例 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),仅供参考