
最近折腾了一把在 Microsoft Teams 里塞 Bot,踩了几个坑,这篇把问题说清楚。从一个空文件夹开始,到 Bot 跑在 Teams 里、接上后端服务、配好 Application Insights 遥测——全程无尿点。
写这篇的原因是:市面上的文档要么太浅,要么跳步跳得理所当然。我打算按我踩坑的顺序来,每个坑都点出来。
你想把一个真正的 chatbot 塞进 Microsoft Teams——不是玩具,是一个能调你自己后端(Logic App、API、编排层,随你有什么)然后回答用户问题的真家伙。你有 Azure 订阅,能写 C#。进去之前不需要是 Teams 或 Bot Framework 专家。
学完你会有:
Teams Bot 不是一件事。它是几个独立的组件,只有全部指向彼此正确才能工作。这是最大的困惑来源,开头就得搞清楚:
Teams 客户端 ──► Azure Bot 资源 ──► App Service (/api/messages) ──► 你的后端 (用户装的 (注册层, (你真正跑的 (Logic App / API / 应用) 知道端点在哪) C# 代码) 编排器)
POST /api/messages。我之前所有的失败,归根结底都是这几个组件指向了错误的地方。把这张图印在脑子里。
dotnet --list-sdks 检查(需要看到一行 8.0.xxx)。dotnet build 当成你唯一的权威构建信号。IDE 的错误面板可能骗人或缓存旧状态,终端不会。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 存放 MicrosoftAppId、MicrosoftAppPassword、MicrosoftAppTenantId、MicrosoftAppType。开箱即用的消息处理器极其简单:
protected override async Task OnMessageActivityAsync( ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{ var replyText = $"Echo: {turnContext.Activity.Text}"; await turnContext.SendActivityAsync( MessageFactory.Text(replyText), cancellationToken);
}
这就是目前 Bot 的全部。它回显你说的任何话。
这是新手容易跳过的步骤,所以后来会卡住。Emulator 让你用零云资源就能验证代码是好的。
dotnet run
Bot 启动在 http://localhost:3978。打开 Emulator → Open Bot → 设置:
http://localhost:3978/api/messages输入 hello → 你会收到 Echo: hello。🎉
这个本地流程没跑通之前不要上云。先把本地循环跑绿,意味着后续云上出了问题,你知道是基础设施或配置问题,而不是代码问题。
回显只是生存证明。现在 Bot 需要:(a) 知道用户是谁,(b) 把问题发给你后端,(c) 把答案漂亮地渲染出来。
创建一个服务(比如 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。如果你的后端要做个性化回答(余额、"我的"相关的一切),这条链路就是全部。从头到尾追踪一遍,确认这个字段确实落到了发出去的请求里。
Teams 默认不会在原始消息 activity 里给你用户邮箱。你得用 TeamsInfo.GetMemberAsync 来拿,它返回一个完整填充的成员对象(邮箱、UPN、姓名)。
我踩过的两个坑:
// 进程内缓存,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——从缓存里读。
纯文本回复在 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 — 见下一节
}
这个坑花了我一整天。用户点击卡片上的按钮时,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 现在原生渲染点击,回显就成了重复消息。)
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 给你入站/出站消息数、用户数,以及——下点功夫的话——每个对话背后的真实身份。如果你没有 Teams 管理中心的权限来拉使用统计,这个尤其重要。
App Insights 有两个包家族,Bot Framework 的遥测集成是基于 2.x 基础包的。如果你拉进来 3.x App Insights 包,会跟 Bot Framework 的 2.x 预期冲突,得到令人抓狂的构建/运行时错误。
最终验证有效的规则:
Microsoft.ApplicationInsights.AspNetCore 固定到 2.22.0。这会传递拉取正确的 2.x 基础包——你不需要直接引用 Microsoft.ApplicationInsights。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>
Bot Framework 自带两个中间件:
TelemetryLoggerMiddleware — 记录 Bot Framework 事件(BotMessageReceived、BotMessageSent 等)。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));
然后把初始化中间件挂到适配器上,让每个回合都流经它。
把你的 App Insights 连接字符串放到配置里(生产环境放到 App Service 应用设置)。连接字符串包含你所在区域的数据摄入端点——别假设默认区域;从 App Insights 资源概览里复制完整字符串。
{ "ApplicationInsights": { "ConnectionString": "<your-app-insights-connection-string>" }
}
用户刀片显示的是匿名化/哈希后的 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、用了多少次。
现在到云端部分。最少需要这些东西:
MicrosoftAppId + 客户端密钥。
MicrosoftAppType。 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(Linux,.NET 8)。然后发布部署。从 Mac 上最可靠的路径(不用 CLI 或 IDE 的不稳定因素)是打包成 zip 通过 Kudu 推送。
dotnet publish -c Release -o ./publish
cd publish && zip -r ../deploy.zip . && cd ..
zip 里必须让你的 Bot 的 .dll 在压缩包根目录,不要嵌套在 publish/ 文件夹里。
Kudu 是 App Service 的管理控制台。两种方式进入:
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 阶段了。
在 Azure Bot 资源上 → Channels(频道)→ 添加 Microsoft Teams。这就是让 Teams 能把消息路由到你端点的开关。
Teams 应用是一个zip,包含三个文件:
teams-app.zip
├── manifest.json
├── color.png (192×192)
└── outline.png (32×32)
图标要求(这些搞错了上传会失败):
color.png — 192×192 像素。把实际标记放在中间 96×96 安全区域内,这样不会被裁切。outline.png — 32×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": []
}
注意事项:
id 和 bots[].botId 都是你的应用注册 App ID。accentColor 匹配你彩色图标的背景(比如白色背景就设 #FFFFFF),这样两者视觉上一致。teams-app.zip。审批通过并安装后,在 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 — 见第六部分。
Microsoft.ApplicationInsights.AspNetCore 固定到 2.22.0。不要混进 3.x。删掉 WebApi 集成包。TelemetryInitializerMiddleware 接受三个参数: (IHttpContextAccessor, TelemetryLoggerMiddleware, bool)。GetMemberAsync(24 小时 TTL,key 为 tenantId:teamsUserId),不要每个回合调两次。messageBack 按钮,不要普通 Action.Submit,规避约 15 秒的卡片动作超时及其误导性的"出了点问题"横幅。/ZipDeployUI 部署,不要用 File Manager 页面。确认 URL。.dll 在压缩包根目录;manifest + 图标在应用包根目录。BotMessageReceived 的 customDimensions.fromName / fromId 里。dotnet build,而不是 IDE 错误面板。全程走完了。空文件夹 → 跑在 Teams 里。按顺序来,每一步检查点绿了再往下走,下一个人真的能复现。