news 2026/9/16 14:25:50

在 Flue 中集成 Discord Channel:从签名验收到 Agent 消息下发的完整实战指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
在 Flue 中集成 Discord Channel:从签名验收到 Agent 消息下发的完整实战指南

在 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完成出站消息下发,并通过dispatchinitialDatadefineTool将 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 项目:

  1. 阅读本地指令(如AGENTS.md)与相关约定;
  2. 识别包管理器与 Flue 目标平台(Node / Cloudflare Workers);
  3. <root>/.flue/<root>/src/<root>/的顺序确定第一个存在的源码根目录;
  4. 检查已有的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/runtimehonovalibot,以及构建侧的@flue/vitevitetypescript

第二步:创建 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; }

模块内各要素的职责拆解

入站 channelcreateDiscordChannel接收publicKeyinteractions回调,返回一个带有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,它严格按以下顺序执行安全校验:

  1. Content-Type 检查:非application/json直接返回415
  2. Content-Length 预检:超过bodyLimit(默认 1 MiB,即1024 * 1024)返回413;非法数字返回400
  3. 签名与时间戳:解析x-signature-ed25519(必须为 64 字节 hex)与x-signature-timestamp,当时间戳缺失、非法或与服务器时钟相差超过5 分钟MAX_SIGNATURE_AGE_SECONDS = 5 * 60)时返回401
  4. 流式读体并限制大小:通过ReadableStream逐块读取,累计超过bodyLimit即终止并返回413(源码中还专门处理了 Node 下reader.cancel()的 rejection 以免进程崩溃);
  5. Ed25519 验签:用 Web Cryptocrypto.subtle.importKey('raw', ..., { name: 'Ed25519' }, ...)导入公钥,对timestamp + body的精确字节做crypto.subtle.verify,失败返回401
  6. PING/PONG 内置处理type === 1时直接返回{ type: 1 },无需业务代码介入;
  7. 其余交互原样透传:保留 Discord 的字段名、嵌套结构与数字判别符,不做归一化改写。

这也是蓝图强调"签名必须基于未消费的原始 body 字节"的原因——任何前置的 JSON 解析或改写都会破坏验签。

目的地派生与安全边界

destinationFromInteraction是一个"应用自有"的辅助函数,它从原生字段推导DiscordDestinationRef(类型定义见 packages/discord/src/index.ts):

  • guild_id存在 →{ type: 'guild', guildId, channelId }
  • context === 2channel.type === 3{ type: 'private', channelId }(私密频道);
  • context === 1channel.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 时都传入相同内容。它携带结构化目的地事实(如channelIdchannelName),Agent 用useInitialData()读取而不是解析实例 ID,外加少量实例级恒定上下文(如频道名);
  • 每条消息各自的事实(如interactionIdcommandName)放在 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/runtimecreateChannelRouter组装;
  • 本指南中所有// 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静态属性:在实例创建时校验派发的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 隧道。

本地验证清单

按蓝图要求,在发布前执行:

  1. 运行项目类型检查(示例仓库为tsc --noEmit)与目标平台的vite build
  2. 生成本地 Ed25519 密钥对,构造带签名的 PING 与命令载荷,本地模拟 Discord 请求;
  3. 覆盖测试:改动过的字节、畸形鉴权(错误签名/过期时间戳/错误 Content-Type)、PING/PONG、/channels/discord/interactions挂载路由、提供商原生载荷透传、channel-agent 延迟导入环;
  4. 在 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.tsagents/assistant.tsapp.ts),可直接对照本文逐步落地。

【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 14:25:41

先楫HPM6750/HPM6450 CAN-FD实战:从协议到采样点调优

简介&#xff1a;面向上海先楫HPM6750与HPM6450高性能MCU的CAN/CAN-FD通信代码工程&#xff0c;适合嵌入式开发者在汽车电子、工业自动化等场景快速落地高速可靠通信。资源共3个文件&#xff0c;约6KB&#xff0c;包含C源码、TXT说明与Markdown文档&#xff1a;其中C文件提供CA…

作者头像 李华
网站建设 2026/9/16 14:25:33

小波模极大值实现奇异点检测:从Lipschitz指数到MATLAB实践

简介&#xff1a;这份资源面向需要掌握小波分析与信号奇异性检测的MATLAB使用者&#xff0c;围绕小波模极大值方法提供了可直接运行的实现脚本。资源聚焦连续高斯小波&#xff08;cgau&#xff09;在信号突变点提取与特征定位中的应用&#xff0c;适合科研入门者或工程开发人员…

作者头像 李华
网站建设 2026/9/16 14:24:26

STM32F407智能报警系统毕设工程详解:从RCC到CAN的外设协同

简介&#xff1a;面向计算机科学与技术、电子信息工程等专业的毕设或课程作业&#xff0c;这是一套完整的STM32F407智能报警系统项目包&#xff0c;涵盖从需求分析、硬件搭建到固件编写、系统联调与测试优化的全流程。项目以Cortex-M4内核的STM32F407为主控&#xff0c;涉及传感…

作者头像 李华
网站建设 2026/9/16 14:24:12

res-downloader 实战指南:视频号、抖音与 m3u8 的无水印资源下载

res-downloader 实战指南&#xff1a;视频号、抖音与 m3u8 的无水印资源下载 【免费下载链接】res-downloader 视频号、小程序、抖音、快手、小红书、直播流、m3u8、酷狗、QQ音乐等常见网络资源下载! 项目地址: https://gitcode.com/GitHub_Trending/re/res-downloader …

作者头像 李华
网站建设 2026/9/16 14:22:53

身份基同态加密实现密封电子拍卖

简介&#xff1a;本资源是一套面向计算机相关专业本科生与研究生的毕业设计/课程设计级区块链安全应用项目&#xff0c;实现基于Hyperledger Fabric的密封电子拍卖协议&#xff0c;并融合身份基同态加密&#xff08;IBHE&#xff09;算法保障投标隐私与可验证性。项目代码经完整…

作者头像 李华
网站建设 2026/9/16 14:19:13

社区智慧养老平台:SpringBoot+Vue全栈实现与权限业务解析

简介&#xff1a;这是一份基于SpringBoot的社区智慧养老监护管理平台完整项目源码包&#xff0c;面向Java初学者、课程设计/毕业设计学生及养老信息化研究者。平台围绕老年人健康监护、生活照护、紧急响应、社交互动等场景&#xff0c;整合SSM框架&#xff0c;实现了多角色权限…

作者头像 李华