site logo

Marico's space

在 Azure 上构建与部署 Microsoft Teams Bot:端到端指南

Others 2026-07-22 17:36:14 5

最近折腾了一把在 Microsoft Teams 里塞 Bot,踩了几个坑,这篇把问题说清楚。从一个空文件夹开始,到 Bot 跑在 Teams 里、接上后端服务、配好 Application Insights 遥测——全程无尿点。

写这篇的原因是:市面上的文档要么太浅,要么跳步跳得理所当然。我打算按我踩坑的顺序来,每个坑都点出来。

目标读者

你想把一个真正的 chatbot 塞进 Microsoft Teams——不是玩具,是一个能调你自己后端(Logic App、API、编排层,随你有什么)然后回答用户问题的真家伙。你有 Azure 订阅,能写 C#。进去之前不需要是 Teams 或 Bot Framework 专家。

学完你会有:

  1. 一个 .NET 8 的 Bot 后端,先回显,再调你后端,渲染富卡片。
  2. Application Insights 遥测,能看到谁在用、问了什么。
  3. 一个开启了 Teams 频道的 Azure Bot 资源。
  4. 部署到 App Service、侧载进 Teams 当正经应用用。

概念模型(动手之前先把这个看明白)

Teams Bot 不是一件事。它是几个独立的组件,只有全部指向彼此正确才能工作。这是最大的困惑来源,开头就得搞清楚:

Teams 客户端 ──► Azure Bot 资源 ──► App Service (/api/messages) ──► 你的后端 (用户装的 (注册层, (你真正跑的 (Logic App / API / 应用) 知道端点在哪) C# 代码) 编排器)
  • 应用注册(Entra ID) — 一个身份。给你一个应用 ID和一个客户端密钥。Bot 以这个身份认证。
  • Azure Bot 资源注册层。它知道你消息端点的 URL,以及哪些频道(Teams、Web Chat 等)开关了。它不跑你的代码
  • App Service — 托管你真正的 Bot 代码,暴露 POST /api/messages
  • Teams 应用包 — 一个 zip(清单 + 图标),侧载后用户能找到并安装 Bot。
  • 你的后端 — 真正干活的地方。Bot 只是它前面的薄薄一层消息层。

我之前所有的失败,归根结底都是这几个组件指向了错误的地方。把这张图印在脑子里。

前置条件

  • .NET 8 SDK — 用 dotnet --list-sdks 检查(需要看到一行 8.0.xxx)。
  • 一个能创建资源的 Azure 订阅。
  • 在你租户里创建应用注册的权限(或者有人能帮你创建)。
  • Bot Framework Emulator,用于本地测试。
  • 一个代码编辑器。VS Code 就行。一个省很多痛苦的建议:把终端里的 dotnet build 当成你唯一的权威构建信号。IDE 的错误面板可能骗人或缓存旧状态,终端不会。

第一部分 — 脚手架 Bot 后端

安装模板(一次性操作)

dotnet new install Microsoft.Bot.Framework.CSharp.EchoBot

创建项目

dotnet new echobot -n TeamsBot
cd TeamsBot

得到一个项目,大致长这样(重要的文件):

TeamsBot/
├── Bots/
│ └── EchoBot.cs ← 你的 Bot 逻辑在这里
├── Controllers/
│ └── BotController.cs ← /api/messages 端点(别动它)
├── Program.cs / Startup.cs ← 依赖注入 wiring
├── appsettings.json ← Bot 凭证 + 配置
└── TeamsBot.csproj

各文件职责:

  • BotController.cs 暴露 POST /api/messages。用户发消息时 Azure Bot Service 调这个端点。你很少需要改它。
  • EchoBot.cs 继承自 ActivityHandler。它有可重写的方法,比如 OnMessageActivityAsync(用户发了消息)和 OnMembersAddedAsync(Bot 被加进聊天)。你的逻辑在这里写。
  • Program.cs / Startup.cs 在依赖注入里注册 Bot、适配器和服务。
  • appsettings.json 存放 MicrosoftAppIdMicrosoftAppPasswordMicrosoftAppTenantIdMicrosoftAppType

开箱即用的消息处理器极其简单:

protected override async Task OnMessageActivityAsync( ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{ var replyText = $"Echo: {turnContext.Activity.Text}"; await turnContext.SendActivityAsync( MessageFactory.Text(replyText), cancellationToken);
}

这就是目前 Bot 的全部。它回显你说的任何话。

在碰 Azure 之前先本地测试

这是新手容易跳过的步骤,所以后来会卡住。Emulator 让你用零云资源就能验证代码是好的。

dotnet run

Bot 启动在 http://localhost:3978。打开 Emulator → Open Bot → 设置:

  • Bot URL: http://localhost:3978/api/messages
  • App IDPassword 留空(本地测试不需要认证)。

输入 hello → 你会收到 Echo: hello。🎉

这个本地流程没跑通之前不要上云。先把本地循环跑绿,意味着后续云上出了问题,你知道是基础设施或配置问题,而不是代码问题。

第二部分 — 把 Bot 接上你的后端

回显只是生存证明。现在 Bot 需要:(a) 知道用户是谁,(b) 把问题发给你后端,(c) 把答案漂亮地渲染出来。

2.1 一个调后端的服务类

创建一个服务(比如 Services/BackendService.cs),职责是拿到用户问题加上用户身份,调你后端,然后把响应交给卡片构建器。大致结构:

public class BackendService
{ private readonly HttpClient _http; private readonly ILogger<BackendService> _log; public BackendService(HttpClient http, ILogger<BackendService> log) { _http = http; _log = log; } // Bot 每收到一个问题就调这个 public async Task<Attachment> AskAndRenderAsync( string question, string loggedInUser, CancellationToken ct) { var answer = await CallBackendAsync(question, loggedInUser, ct); return CardFactory.BuildAnswerCard(answer); // 见 2.3 } private async Task<BackendResponse> CallBackendAsync( string question, string loggedInUser, CancellationToken ct) { var payload = new BackendRequest { Question = question, LoggedInUser = loggedInUser // 身份从这里透传 }; var resp = await _http.PostAsJsonAsync("<your-backend-endpoint>", payload, ct); resp.EnsureSuccessStatusCode(); return await resp.Content.ReadFromJsonAsync<BackendResponse>(cancellationToken: ct); }
}

关键思路:解析出的用户身份必须一路透传——从 Teams activity,到 AskAndRenderAsync,到你后端收到的请求 payload。如果你的后端要做个性化回答(余额、"我的"相关的一切),这条链路就是全部。从头到尾追踪一遍,确认这个字段确实落到了发出去的请求里。

2.2 获取真实用户(邮箱)— 并且要缓存

Teams 默认不会在原始消息 activity 里给你用户邮箱。你得用 TeamsInfo.GetMemberAsync 来拿,它返回一个完整填充的成员对象(邮箱、UPN、姓名)。

我踩过的两个坑:

  1. 每个回合不要调两次。这是一个 roster API 调用。如果你在诊断块生产代码里都调了,延迟翻倍——这很要命(见 2.4 的超时陷阱)。
  2. 要缓存。成员不会每个回合都变,所以在进程内缓存。
// 进程内缓存,24 小时 TTL,key 为 tenantId:teamsUserId
private static readonly ConcurrentDictionary<string, (string Email, DateTime CachedAt)> _memberCache = new(); private async Task<string> ResolveEmailAsync( ITurnContext turnContext, CancellationToken ct)
{ var tenantId = turnContext.Activity.Conversation?.TenantId ?? "unknown"; var userId = turnContext.Activity.From?.Id ?? "unknown"; var key = $"\{tenantId}:\{userId}"; if (_memberCache.TryGetValue(key, out var hit) && (DateTime.UtcNow - hit.CachedAt) < TimeSpan.FromHours(24)) { return hit.Email; } var member = await TeamsInfo.GetMemberAsync(turnContext, userId, ct); var email = member?.Email ?? member?.UserPrincipalName ?? "unknown"; _memberCache[key] = (email, DateTime.UtcNow); return email;
}

小贴士:开发阶段可以留一个小诊断块,把原始 activity 和 GetMemberAsync 结果 dump 到日志里。这是证明邮箱真的拿到了,而不是靠猜。注意别让诊断代码调用两次 API——从缓存里读。

2.3 用 Adaptive Card 渲染答案

纯文本回复在 Teams 里很难看。用 Adaptive Card。让我保持理智的模式:一个卡片构建方法作为渲染的唯一真实来源——不要在代码库里散落卡片逻辑,而且(血的教训)不要在热路径上让 LLM 生成卡片 JSON。确定性工厂更快、更便宜,而且不会在生产环境里给你惊喜。

public static class CardFactory
{ public static Attachment BuildAnswerCard(BackendResponse r) { var card = new { type = "AdaptiveCard", version = "1.4", body = new object[] { new { type = "TextBlock", text = r.Answer, wrap = true } }, actions = r.Suggestions.Select(BuildMessageBackAction).ToArray() }; return new Attachment { ContentType = "application/vnd.microsoft.card.adaptive", Content = card }; } // BuildMessageBackAction — 见下一节
}

2.4 按钮超时陷阱(重要)

这个坑花了我一整天。用户点击卡片上的按钮时,Teams 用的是 Action.Submit 契约,要求你的 Bot 大约 15 秒内返回 HTTP 200。如果你的后端 + 渲染耗时更长,Teams 会显示"出了点问题,请重试。"横幅——哪怕正确答案稍后正常到达。看起来是坏了,其实没坏。

修复方法:用 Teams 的 messageBack 格式代替普通的 Action.Submit。用 messageBack,Teams 把按钮点击当成用户正常打字发消息处理——完全绕过了卡片动作确认超时,而且点击会渲染成正经的用户气泡。

private static object BuildMessageBackAction(string label) => new
{ type = "Action.Submit", title = label, data = new { msteams = new { type = "messageBack", text = label, // 显示为用户消息 displayText = label } }
};

因为点击现在作为普通消息到达,你原有的消息处理器已经知道怎么路由它们了——不需要单独处理点击的路径。(如果你之前有"把问题回显一下"的逻辑,删掉它;Teams 现在原生渲染点击,回显就成了重复消息。)

2.5 接入消息处理器

protected override async Task OnMessageActivityAsync( ITurnContext<IMessageActivity> turnContext, CancellationToken ct)
{ var question = turnContext.Activity.Text?.Trim(); var email = await ResolveEmailAsync(turnContext, ct); var card = await _backend.AskAndRenderAsync(question, email, ct); await turnContext.SendActivityAsync(MessageFactory.Attachment(card), ct);
}

重新构建,用 Emulator 再跑一遍,确认真实后端返回的答案渲染成了卡片。绿了?继续。

第三部分 — Application Insights(看看谁在用)

不能量化就无从管理。Application Insights 给你入站/出站消息数、用户数,以及——下点功夫的话——每个对话背后的真实身份。如果你没有 Teams 管理中心的权限来拉使用统计,这个尤其重要。

3.1 NuGet 包陷阱(这个坑能浪费你几小时)

App Insights 有两个包家族,Bot Framework 的遥测集成是基于 2.x 基础包的。如果你拉进来 3.x App Insights 包,会跟 Bot Framework 的 2.x 预期冲突,得到令人抓狂的构建/运行时错误。

最终验证有效的规则:

  • Microsoft.ApplicationInsights.AspNetCore 固定到 2.22.0。这会传递拉取正确的 2.x 基础包——你不需要直接引用 Microsoft.ApplicationInsights
  • 不要加 3.x 包(Microsoft.ApplicationInsights 3.x、Microsoft.ApplicationInsights.AspNetCore 3.x)。
  • 不要Microsoft.Bot.Builder.Integration.ApplicationInsights.WebApi——它面向 .NET Framework,不是 .NET Core,不应该出现在 .NET 8 项目里。

验证可用的包组合:

<ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" /> <PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" /> <PackageReference Include="Microsoft.Bot.Builder.ApplicationInsights" Version="4.23.1" /> <PackageReference Include="Microsoft.Bot.Builder.Integration.ApplicationInsights.Core" Version="4.23.1" /> <PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.1" />
</ItemGroup>

3.2 注册遥测中间件

Bot Framework 自带两个中间件:

  • TelemetryLoggerMiddleware — 记录 Bot Framework 事件(BotMessageReceivedBotMessageSent 等)。
  • TelemetryInitializerMiddleware — 给这些事件补充请求上下文。

我踩过的坑:TelemetryInitializerMiddleware 构造函数是 (IHttpContextAccessor, TelemetryLoggerMiddleware, bool)——三个参数。如果你用了两个参数的写法,会得到编译错误。注册大致这样:

// Startup.cs — ConfigureServices
services.AddApplicationInsightsTelemetry();
services.AddHttpContextAccessor(); services.AddSingleton<IBotTelemetryClient, BotTelemetryClient>();
services.AddSingleton<TelemetryLoggerMiddleware>();
services.AddSingleton<TelemetryInitializerMiddleware>(sp => new TelemetryInitializerMiddleware( sp.GetRequiredService<IHttpContextAccessor>(), sp.GetRequiredService<TelemetryLoggerMiddleware>(), logPersonalInformation: true));

然后把初始化中间件挂到适配器上,让每个回合都流经它。

3.3 配置:连接字符串

把你的 App Insights 连接字符串放到配置里(生产环境放到 App Service 应用设置)。连接字符串包含你所在区域的数据摄入端点——别假设默认区域;从 App Insights 资源概览里复制完整字符串。

{ "ApplicationInsights": { "ConnectionString": "<your-app-insights-connection-string>" }
}

3.4 在数据里找到真实用户

用户刀片显示的是匿名化/哈希后的 ID,不是 Teams 显示名——所以第一眼看会觉得没用。真实身份在 BotMessageReceived 事件的 custom dimensions 里。在 App Insights → 日志(KQL):

customEvents
| where name == "BotMessageReceived"
| extend userName = tostring(customDimensions.fromName), userId = tostring(customDimensions.fromId)
| summarize messages = count() by userName, userId
| order by messages desc

这样你就能拿到一个真实的排行榜:谁在用 Bot、用了多少次。

第四部分 — 创建 Azure Bot 资源(频道注册)

现在到云端部分。最少需要这些东西:

  1. Entra ID 里的应用注册 → 给你 MicrosoftAppId + 客户端密钥。
    • 第一天就把密钥放进 Key Vault。不要硬编码、不要提交。未来的你面对泄露或过期的密钥散落在十几个配置文件里时,会感谢现在的你。
    • 注意注册是单租户还是多租户——这决定你的 MicrosoftAppType
  2. Azure Bot 资源 → 注册层。创建它,设置消息端点为:
 https://<your-app-service>.azurewebsites.net/api/messages

(App Service 会在第五部分创建;可以先创建 Bot,之后再回来填这个。)

本地开发配置

本地开发时,appsettings.json 需要凭证——但永远不要提交真实值

{ "MicrosoftAppType": "SingleTenant", "MicrosoftAppId": "<your-app-id>", "MicrosoftAppPassword": "<your-secret>", "MicrosoftAppTenantId": "<your-tenant-id>"
}

比改文件更好的方式:用 User Secrets,让凭证完全放在项目文件夹外面:

dotnet user-secrets init
dotnet user-secrets set "MicrosoftAppId" "<your-app-id>"
dotnet user-secrets set "MicrosoftAppPassword" "<your-secret>"
dotnet user-secrets set "MicrosoftAppTenantId" "<your-tenant-id>"
dotnet user-secrets set "MicrosoftAppType" "SingleTenant"

防止意外提交:

echo "appsettings.json" >> .gitignore
echo "appsettings.Development.json" >> .gitignore

生产环境里,App Service 从它的应用设置 / Key Vault 里读取这些值,不从提交的文件里读。

用真实凭证做一次完整性检查

部署之前,用 Emulator 再跑一遍,但这次填上App ID 和 Password。如果还能回显,说明你的应用注册凭证有效,Bot 可以认证——这是个巨大的检查点,把认证问题和部署问题隔离开了。这里报 401 说明密钥不对,或者 MicrosoftAppType/MicrosoftAppTenantId 和注册不匹配。

第五部分 — 部署到 App Service

在你的资源组里创建一个 App Service(Linux,.NET 8)。然后发布部署。从 Mac 上最可靠的路径(不用 CLI 或 IDE 的不稳定因素)是打包成 zip 通过 Kudu 推送。

构建可部署的 zip

dotnet publish -c Release -o ./publish
cd publish && zip -r ../deploy.zip . && cd ..

zip 里必须让你的 Bot 的 .dll 在压缩包根目录,不要嵌套在 publish/ 文件夹里。

通过 Kudu Zip Push Deploy 推送

Kudu 是 App Service 的管理控制台。两种方式进入:

  • 门户:App Service → 开发工具 → 高级工具 → 转到 → 在 Kudu 里,工具 → Zip Push Deploy
  • 直接 URL: https://<your-app-service>.scm.azurewebsites.net/ZipDeployUI

确认 URL 以 /ZipDeployUI 结尾再放文件。有一个看起来很像的 File Manager / Debug Console 页面——如果你把 zip 丢到那里,它只是把 zip 当文件上传,不会解压也不会部署,而且会"成功"地静默完成。/ZipDeployUI 页面写着"拖一个 zip 到这里来部署",会解压到 /site/wwwroot,并重启应用。

deploy.zip 拖进去 → 看部署日志滚动 → 最后显示成功。

验证

打开 https://<your-app-service>.azurewebsites.net。首次加载可能需要 30–60 秒(冷启动)。你应该看到"Your bot is ready!"

现在回到 Azure Bot 资源,确认它的消息端点指向这个 App Service 的 /api/messages。用 Bot 资源的Web Chat 里测试刀片发一条"hello"——如果回显/回复了,说明端点 wiring 正确,可以进入 Teams 阶段了。

第六部分 — 开启 Teams 频道并推送到 Teams

开启频道

Azure Bot 资源上 → Channels(频道)→ 添加 Microsoft Teams。这就是让 Teams 能把消息路由到你端点的开关。

构建 Teams 应用包

Teams 应用是一个zip,包含三个文件:

teams-app.zip
├── manifest.json
├── color.png (192×192)
└── outline.png (32×32)

图标要求(这些搞错了上传会失败):

  • color.png192×192 像素。把实际标记放在中间 96×96 安全区域内,这样不会被裁切。
  • outline.png32×32 像素,白色图案、背景透明。这是 Teams 在侧边栏显示的单色图标。

一个最小化 manifest.json

{ "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json", "manifestVersion": "1.16", "version": "1.0.0", "id": "<your-app-id>", "packageName": "com.example.teamsbot", "developer": { "name": "Your Team", "websiteUrl": "https://example.com", "privacyUrl": "https://example.com/privacy", "termsOfUseUrl": "https://example.com/terms" }, "name": { "short": "TeamsBot", "full": "TeamsBot Assistant" }, "description": { "short": "Ask questions, get answers.", "full": "A Teams assistant that answers questions from our backend knowledge base." }, "icons": { "color": "color.png", "outline": "outline.png" }, "accentColor": "#FFFFFF", "bots": [ { "botId": "<your-app-id>", "scopes": ["personal", "team", "groupchat"], "supportsFiles": false, "isNotificationOnly": false } ], "permissions": ["identity", "messageTeamMembers"], "validDomains": []
}

注意事项:

  • idbots[].botId 都是你的应用注册 App ID
  • 设置 accentColor 匹配你彩色图标的背景(比如白色背景就设 #FFFFFF),这样两者视觉上一致。
  • 三个文件平铺打包成 zip(不要外层文件夹),和部署 zip 的规则一样。

侧载 / 发布

  • 侧载(用于测试):Teams → 应用 → 管理应用 → 上传一个应用 → 上传自定义应用 → 选择你的 teams-app.zip
  • 全组织发布:通过 Teams 管理中心提交。在很多租户里侧载是受限的,所以即使测试可能也需要管理员审批才能看到应用。把这个审批步骤算进去——经常就是这东西卡在"我能用"和"大家都能用"之间。

审批通过并安装后,在 Teams 里给 Bot 发消息。你应该收到后端驱动的卡片回复,几分钟内流量就会出现在 Application Insights 里。

附录 — 所有配置文件汇总

TeamsBot.csproj(相关包)

<ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.11" /> <PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.22.0" /> <PackageReference Include="Microsoft.Bot.Builder.ApplicationInsights" Version="4.23.1" /> <PackageReference Include="Microsoft.Bot.Builder.Integration.ApplicationInsights.Core" Version="4.23.1" /> <PackageReference Include="Microsoft.Bot.Builder.Integration.AspNet.Core" Version="4.23.1" />
</ItemGroup>

appsettings.json(占位符——真实值通过 User Secrets / Key Vault 注入)

{ "MicrosoftAppType": "SingleTenant", "MicrosoftAppId": "<your-app-id>", "MicrosoftAppPassword": "<your-secret>", "MicrosoftAppTenantId": "<your-tenant-id>", "ApplicationInsights": { "ConnectionString": "<your-app-insights-connection-string>" }, "Backend": { "Endpoint": "<your-backend-endpoint>" }
}

manifest.json — 见第六部分。

坑点清单(收藏备用)

  • 在碰 Azure 之前先把本地 Emulator 跑绿。把代码 bug 和基础设施 bug 分开。
  • Microsoft.ApplicationInsights.AspNetCore 固定到 2.22.0不要混进 3.x。删掉 WebApi 集成包。
  • TelemetryInitializerMiddleware 接受三个参数: (IHttpContextAccessor, TelemetryLoggerMiddleware, bool)
  • 缓存 GetMemberAsync(24 小时 TTL,key 为 tenantId:teamsUserId),不要每个回合调两次。
  • messageBack 按钮,不要普通 Action.Submit,规避约 15 秒的卡片动作超时及其误导性的"出了点问题"横幅。
  • 一个确定性的卡片工厂作为唯一渲染路径。热路径上避免用 LLM 生成卡片。
  • 第一天就把密钥放进 Key Vault。永远不要硬编码、永远不要提交。
  • 通过 /ZipDeployUI 部署,不要用 File Manager 页面。确认 URL。
  • zip 扁平化——Bot .dll 在压缩包根目录;manifest + 图标在应用包根目录。
  • 图标:color 192×192(标记在 96×96 安全区域内),outline 32×32 白色透明背景。
  • 算上 Teams 管理员审批步骤,如果你们租户限制了侧载的话。
  • 用户刀片显示哈希 ID;真实身份在 BotMessageReceivedcustomDimensions.fromName / fromId 里。
  • 相信终端的 dotnet build,而不是 IDE 错误面板。

全程走完了。空文件夹 → 跑在 Teams 里。按顺序来,每一步检查点绿了再往下走,下一个人真的能复现。