在 Flue 中接入 Microsoft Teams Channel:Bot Connector 认证、入站活动与项目自有出站消息的完整实现指南
发布时间:2026/9/16 16:42:20 作者:尧图编辑部 阅读量:1,286

在 Flue 中接入 Microsoft Teams ChannelBot Connector 认证、入站活动与项目自有出站消息的完整实现指南【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue导读本文以 Flue 仓库中的官方蓝图 blueprints/channel--teams.md 为核心骨架结合 flue/teams 包源码与 examples/teams-channel 完整示例系统讲解如何在 Flue 项目中接入 Microsoft Teams从 OAuth 客户端凭据换取访问令牌、校验 Bot Connector JWT 与活动签名到创建渠道、挂载路由、绑定 Agent 并实现项目自有的出站消息发送。读完本文你将能够在 Node 与 Cloudflare Workers 上以同一份 Fetch 代码实现一个认证完整、可回复、可主动发消息的 Teams 机器人。一、为什么需要项目自有的 Teams 客户端Microsoft 官方维护的microsoft/agents-hosting与microsoft/teams.apps等包当前声明的是 Node 运行时并依赖 Node 导向的 MSAL、JWT、HTTP 与 Express 基础设施。这在 Node 环境下可行却无法直接运行在 Cloudflare Workers 这类 Fetch 运行时上。Flue 的解决思路是只使用微软官方文档公开的 OAuth 客户端凭据流程client credentials与 Bot Connector REST 协议全部通过 Fetch 实现从而让同一份项目代码在 Node 与 Cloudflare Workers 上行为一致。# 安装渠道包与校验库valibot 用于声明式参数校验 pnpm add flue/teams valibot需要注意flue/teams包自身依赖botframework-schema用于提供类型化的 Bot Framework Activity、hono路由与joseJWT 验证并以flue/runtime作为 peer dependency见 packages/teams/package.json。按 Flue 通用渠道蓝图 blueprints/channel.md 的约定安装后应首先确定项目的 Flue 源码根目录依次尝试root/.flue/、root/src/、root/并把后续文件放到该根目录下的lib/与channels/中。二、创建 Fetch 客户端OAuth 令牌缓存与 Connector 消息投递出站消息需要两类凭据Bot 应用标识TEAMS_APP_ID、租户标识TEAMS_TENANT_ID与应用密码TEAMS_APP_PASSWORD。在source-dir/lib/teams-client.ts中实现并导出一个窄接口的createTeamsClient(...)在https://login.microsoftonline.com/tenant/oauth2/v2.0/token上以grant_typeclient_credentials交换令牌请求 scope 为https://api.botframework.com/.default缓存访问令牌直到临近expires_in过期时间示例实现中在过期前 60 秒即视为失效并重新换取见 examples/teams-channel/src/lib/teams-client.ts向已验证目标的serviceUrl/v3/conversations/conversationId/activitiesPOST 消息活动若为频道线程回复则追加/threadId路径段conversationId、threadId、botId、serviceUrl必须来自可信的应用代码渠道通过initialData注入而非模型参数支持注入 Fetch 实现便于本地与 workerd 环境测试。示例中定义TeamsMessageRef时只挑选TeamsConversationRef中发消息真正需要的四个字段serviceUrl、conversationId、botId、threadId从而把出站接口收窄到最小面export type TeamsMessageRef Pick TeamsConversationRef, serviceUrl | conversationId | botId | threadId ;发消息时请求体包含type: message、from.id botId、conversation.id conversationId在线程回复时带上replyToId: threadId。OAuth 与 Connector 的响应状态与形状都必须校验OAuth 响应缺少access_token或非有限数值的expires_in即抛错Connector 非 2xx 或缺少资源id同样抛错。任何由模型或未认证调用方直接提供的 serviceUrl 都绝不能作为连接目标。三、创建渠道认证入口与事件分发在source-dir/channels/teams.ts中创建渠道。核心入口是flue/teams的createTeamsChannel({ appId, tenantId, activities })// flue-blueprint: channel/teams1 import { defineTool, dispatch } from flue/runtime; import * as v from valibot; import { createTeamsChannel } from flue/teams; import { Assistant } from ../agents/assistant.ts; import { createTeamsClient, type TeamsMessageRef } from ../lib/teams-client.ts; const appId process.env.TEAMS_APP_ID!; const tenantId process.env.TEAMS_TENANT_ID!; export const client createTeamsClient({ appId, tenantId, appPassword: process.env.TEAMS_APP_PASSWORD!, }); export const channel createTeamsChannel({ appId, tenantId, // Path: /channels/teams/activities async activities({ activity }) { if (activity.type ! message || !activity.text) return; const destination channel.destination(activity); await dispatch(Assistant, { id: channel.instanceId(destination), // Recorded once when this event creates the instance; ignored after. initialData: { serviceUrl: destination.serviceUrl, conversationId: destination.conversationId, botId: destination.botId, ...(destination.threadId undefined ? {} : { threadId: destination.threadId }), ...(activity.conversation.name undefined ? {} : { conversationName: activity.conversation.name }), }, message: { kind: signal, type: teams.message, body: activity.text, attributes: { ...(activity.id undefined ? {} : { activityId: activity.id }), senderId: activity.from.id, senderName: activity.from.name, }, }, }); }, }); export function postMessage(ref: TeamsMessageRef) { return defineTool({ name: post_teams_message, description: Post a message to the Microsoft Teams conversation bound to this agent., input: v.object({ text: v.pipe(v.string(), v.minLength(1)) }), async run({ data }) { const { text } data; const result await client.postMessage(ref, text); return { output: { activityId: result.id } }; }, }); }3.1 入站验证链路源码级回调中的activity是已经过验证的 Bot Framework 原生活动类型来自botframework-schema。验证发生在 packages/teams/src/routes.ts 的 activities 处理器中顺序如下content-type必须是application/json否则返回415校验content-length默认 1 MiB 上限可通过bodyLimit配置超限返回413用 packages/teams/src/auth.ts 的createBotFrameworkTokenVerifier校验Authorization: Bearer JWT要求algRS256且携带kid通过 OpenID 元数据默认https://login.botframework.com/v1/.well-known/openidconfiguration发现 JWKS校验audienceappId、issuerhttps://api.botframework.com、exp与serviceurl声明容忍 5 分钟时钟偏差请求体解析为对象后检查channelId msteams否则 403、JWT 签名密钥的endorsements包含msteams否则 401、活动serviceUrl与 JWTserviceurl声明一致否则 401、活动携带的租户 id 与配置的tenantId一致否则 403。签名密钥集根据cache-control的max-age缓存下限 60 秒、上限 24 小时未知kid会触发一次强制刷新且有 30 秒冷却避免发现服务故障时每个请求都去打上游见 packages/teams/src/auth.ts。3.2 从活动推导规范路由身份channel.destination(activity)返回规范的TeamsConversationRef见 packages/teams/src/index.ts字段含义tenantId固定租户标识serviceUrl验证过的 Connector 服务地址conversationId会话 idscopepersonal/groupChat/channel/unknownbotId接收方BotidthreadId?频道场景下的线程 id取replyToId或活动idteamId?/channelId?来自channelData的团队/频道 id推导逻辑要求serviceUrl、conversation.id、recipient.id三者齐全且 serviceUrl 为合法 HTTPS URL见 packages/teams/src/routes.tsscope由conversation.conversationType与是否存在channelData.team/channelData.channel共同决定。channel.instanceId(ref)则把这些字段编码为命名空间化的实例 idteams:v1:tenant:scope:...逐字段encodeURIComponentparseInstanceId(id)只解析这类规范 id且会反向重算校验一致性见 packages/teams/src/index.ts。实例 id 只是寻址标识不是授权凭据——直接暴露的 Agent 路由在使用调用方选择的实例 id 绑定出站操作前必须独立完成授权。3.3 事件类型的处理策略回调可按activity.type分发message、conversationUpdate、invoke、messageReaction等 Bot Framework 类型都使用微软文档规定的字段名。返回undefined会产生空200返回 JSON 兼容值会序列化为 JSON 响应体用于invoke响应也可以直接返回Response或使用 Hono 的c上下文做显式状态与响应控制。需要说明的是本渠道不做投递去重若重复分发不可接受应在应用自有持久化存储中认领活动 id参见 examples/teams-channel/README.md。3.4initialData与会话绑定initialData是实例的创建数据仅在事件创建实例时记录一次之后被忽略但渠道会在每次 dispatch 时都带上它。它携带出站工具抵达会话所需的全部目的地事实serviceUrl、conversationId、botId、可选threadId以及少量实例级不变的上下文如会话显示名conversationName。逐消息可变的事实如senderId、senderName、activityId则放在 signal 的attributes上二者职责清晰。四、挂载渠道路由只在 app.ts 挂载处生效渠道只在其被app.ts挂载的位置提供 HTTP 路由。channel.route()是纯路由工厂返回相对于挂载点的 Hono 子应用// app.ts import { Hono } from hono; import { channel } from ./channels/teams.ts; const app new Hono(); app.route(/channels/teams, channel.route()); export default app;flue/teams内部通过createChannelRouter把单条POST /activities路由定义包装为可挂载的 Hono 子应用见 packages/teams/src/index.ts。约定挂载为/channels/teams时Azure Bot 消息端点应配置为https://example.com/channels/teams/activities更换挂载路径会使所有 provider URL 相应位移蓝图中的// Path:注释均以约定挂载为前提。示例工程还额外挂载了app.route(/agents/assistant, createAgentRouter(Assistant))使 Agent 同时可通过 HTTP 直接访问见 examples/teams-channel/src/app.ts。五、绑定 AgentuseInitialData与use agent指令在source-dir/agents/assistant.ts中编写 Agentuse agent; import { useInitialData, useModel, useTool } from flue/runtime; import * as v from valibot; import { postMessage } from ../channels/teams.ts; const initialDataSchema v.object({ serviceUrl: v.string(), conversationId: v.string(), botId: v.string(), threadId: v.optional(v.string()), conversationName: 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 Microsoft Teams channel dispatch.); useTool(postMessage(data)); const conversationName data.conversationName ? ${data.conversationName} : ; return Reply concisely in the bound Microsoft Teams conversation${conversationName}.; } Assistant.initialData initialDataSchema;要点Assistant.initialData静态属性实例创建时用 valibot schema 校验渠道 dispatch 来的initialData不合法即拒绝创建useInitialData()每次渲染返回解析后的值。use agent指令必须是模块第一句它把 Agent 注册进应用渠道回调里的dispatch(...)因此无需在app.ts中挂载。仅当 Agent 需要被 HTTP 直接访问时才在app.ts追加app.route(/agents/name, createAgentRouter(Assistant))来自flue/runtime/routing。渠道与 Agent 的循环导入渠道 import Agent、Agent import 渠道的postMessage之所以安全是因为导入的绑定只在延迟回调activities 回调与 Agent 函数体内被读取模块求值阶段并不使用参见 examples/teams-channel/README.md。模型应通过useTool获得post_teams_message工具工具输入用 valibot 约束为非空字符串text输出返回 Connector 分配的活动 id模型因此只决定说什么而发给谁由应用绑定的ref决定——这正是绑定可信目的地、不向模型暴露任意目的地的安全设计。六、凭据、主权云与本地验证6.1 凭据含义环境变量作用TEAMS_APP_ID约束 Bot Connector JWT 的 audience受众TEAMS_TENANT_ID约束活动的租户身份TEAMS_APP_PASSWORD出站 OAuth 客户端凭据认证6.2 主权云Sovereign Cloud适配flue/teams默认使用微软公有云的 OpenID 元数据https://login.botframework.com/v1/.well-known/openidconfiguration与令牌签发者https://api.botframework.com。对受支持的主权云可向createTeamsChannel(...)传入该云的元数据 URL 与 issuer对应openIdMetadataUrl、tokenIssuer选项并在项目自有的客户端中配置匹配的 OAuth authorityTEAMS_OAUTH_AUTHORITY。示例工程 examples/teams-channel/src/channels/teams.ts 通过三个可选环境变量TEAMS_OPENID_METADATA_URL、TEAMS_TOKEN_ISSUER、TEAMS_OAUTH_AUTHORITY实现这套覆盖。务必遵循项目既有 secret 约定绝不臆造凭据值。6.3 端点与权限Azure Bot 消息端点设为渠道挂载路径加路由后缀即上文https://example.com/channels/teams/activitiesBot 默认只在被 提及 时收到频道消息仅当应用需要接收所有频道或群聊消息时才添加 Teams 资源专属同意resource-specific consent权限真实投递需要一个公网 HTTPS 端点与已配置的 Azure Bot 消息端点。6.4 本地验证清单按蓝图要求完成以下验证全程不接触微软服务运行项目类型检查tsc --noEmit/ 项目对应 typecheck 命令与面向目标平台的vite build本地生成 RSA 密钥对、OpenID 元数据、JWKS 以及签名的 Bot Connector JWT分别测试合法/非法的 audience、issuer、过期时间、endorsement、serviceUrl、tenant 与活动载荷通过注入的本地 Fetch 传输演练一次 OAuth 与一次出站消息。flue/teams的fetch选项仅用于 OpenID 元数据与签名密钥发现同样支持注入以便测试JWT 验证完全在本地通过jose完成密钥导入后按kid缓存CryptoKey不会反复导入。七、升级既有集成时的注意事项channel--teams.md蓝图自带升级说明当前版本为Version 12026-06-14即初始版本。升级既有集成时应把现有实现与本完整蓝图逐条对比应用所有相关变更、保留项目自定义内容并在实现完全符合后在主标记文件primary marked file中添加或更新flue-blueprint: channel/teams1标记。当标记缺失时本次对比是强制的。结语通过 flue/teams 的认证化 activities 端点与项目自有的 Fetch 客户端Flue 将 Microsoft Teams 接入收敛为一个固定应用 一个固定租户 一份可信目的地绑定的清晰模型入站侧由包完成 OpenID 发现、JWT 校验、endorsement 与服务 URL 核对出站侧由应用代码通过initialData注入可信引用仅把说什么交给模型。整套方案不依赖 Node 专属基础设施因而在 Node 与 Cloudflare Workers 上均可运行。完整可运行参考见 examples/teams-channel通用渠道设计原则可对照 blueprints/channel.md。【免费下载链接】flueThe sandbox agent framework.项目地址: https://gitcode.com/GitHub_Trending/flue1/flue创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考