使用 Microsoft Graph 构建 Bot 框架 bot
本教程指导你如何生成使用 Microsoft Graph API 检索用户的日历信息的 Bot Framework 机器人。
提示
如果只想下载已完成的教程,可以下载或克隆GitHub存储库。 有关使用应用 ID 和密码配置应用的说明,请参阅演示文件夹中的自述文件。
先决条件
在开始本教程之前,应在开发计算机上安装以下内容。
您还应该有一个在 Outlook.com 上拥有邮箱的个人 Microsoft 帐户,或者一个 Microsoft 工作或学校帐户。 如果你没有 Microsoft 帐户,则有几个选项可以获取免费帐户:
- 你可以 注册新的个人 Microsoft 帐户。
- 你可以注册开发人员计划Microsoft 365免费订阅Microsoft 365订阅。
- Azure 订阅。 如果没有,请创建一个 免费 帐户,然后再开始。
备注
本教程是使用以下版本编写的。 本指南中的步骤可能与其他版本一起运行,但该版本尚未经过测试。
- .NET Core SDK 版本 5.0.302
- Bot Framework Emulator 4.1.3
- ngrok 2.3.40
反馈
Please provide any feedback on this tutorial in the GitHub repository.
创建 Bot 框架项目
在此部分中,你将创建 Bot Framework 项目。
在要创建项目的 (CLI) 打开命令行接口。 运行以下命令以使用 Microsoft.Bot.Framework.CSharp.EchoBot 模板创建新 项目。
dotnet new echobot -n GraphCalendarBot
备注
如果收到错误
No templates matched the input template name: echobot.
,请通过以下命令安装模板,然后重新运行上一个命令。dotnet new -i Microsoft.Bot.Framework.CSharp.EchoBot
将默认的 EchoBot 类重命名为 CalendarBot。 打开 ./Bots/EchoBot.cs ,将 的所有实例替换为
EchoBot
CalendarBot
。 将文件重命名为 CalendarBot.cs。将 替换为
EchoBot``CalendarBot
其余 .cs 文件的所有实例。在 CLI 中,将当前目录更改为 GraphCalendarBot 目录并运行以下命令以确认项目生成。
dotnet build
添加 Nuget 程序包
在继续之前,请安装一些NuGet程序包,你稍后会使用它。
- AdaptiveCards ,允许机器人在响应中发送自适应卡片。
- Microsoft.Bot.Builder.Dialogs ,用于向自动程序添加对话框支持。
- Microsoft.Recognizers.Text.DataTypes.TimexExpression 将自动程序提示返回的 TIMEX 表达式转换为 DateTime 对象。
- Microsoft.Graph 用来呼叫 Microsoft Graph。
在 CLI 中运行以下命令以安装依赖项。
dotnet add package AdaptiveCards --version 2.7.1 dotnet add package Microsoft.Bot.Builder.Dialogs --version 4.14.1 dotnet add package Microsoft.Bot.Builder.Integration.AspNet.Core --version 4.14.1 dotnet add package Microsoft.Recognizers.Text.DataTypes.TimexExpression --version 1.8.0 dotnet add package Microsoft.Graph --version 4.1.0
测试机器人
在添加任何代码之前,请测试自动程序以确保其正常工作,并确保Bot Framework Emulator配置为测试它。
通过运行以下命令启动自动程序。
dotnet run
提示
虽然可以使用任何文本编辑器编辑项目中的源文件,但我们建议使用Visual Studio Code。 Visual Studio Code调试支持、Intellisense 等。 如果使用 Visual Studio Code,可以使用 RunStart -> 调试 菜单启动自动程序。
打开浏览器并进入 ,确认机器人正在运行
http://localhost:3978
。 你应该会看到你的 机器人已准备就绪! 消息。打开Bot Framework Emulator。 选择" 文件" 菜单,然后选择" 打开自动程序"。
在
http://localhost:3978/api/messages
自动程序 URL 中输入,然后选择"连接"。聊天机器人在聊天
Hello and welcome!
窗口中进行响应。 向自动程序发送消息并确认它会回显。
在门户中注册 bot
在此练习中,你将使用 Azure 门户创建新的自动Azure AD通道注册和 Web 应用程序注册。
创建自动程序频道注册
打开浏览器并导航到 Azure 门户。 使用与 Azure 订阅关联的帐户登录。
选择左上角的菜单,然后选择" 创建资源"。
在"新建" 页面上,搜索并选择
Azure Bot
"Azure 自动程序"。在 "Azure 自动程序" 页面上,选择" 创建"。
填写必填字段,将 消息终结点留空 。 自动 程序句柄 字段必须是唯一的。 请务必查看不同的定价层,并选择适合你的方案的情况。 如果这只是一个学习练习,你可能想要选择免费选项。
对于 "Microsoft 应用 ID", 选择"创建新的 Microsoft 应用 ID"。
然后“审阅 + 创建”。 验证完成后,选择"创建 "。
部署完成后,选择" 转到资源"。
在"设置"下,选择"配置"。 选择 " Microsoft 应用 ID"旁边的 "管理"链接。
选择“新建客户端密码”。 添加说明并选择过期,然后选择"添加 "。
离开此页前,先复制客户端密码值。 你将在以下步骤中需要它。
重要
此客户端密码不会再次显示,所以请务必现在就复制它。 你需要在多个位置输入此值,以便保持安全。
在 左侧 菜单中选择"概述"。 复制 Application (客户端) ID 并保存它,以下步骤中将需要该值。
返回到浏览器中的自动程序通道注册窗口,然后将应用程序 ID 粘贴到 Microsoft 应用 ID 字段中。 将客户端密码粘贴到 "密码" 字段中。 选择“确定”。
在" 自动程序通道注册"页上, 选择"创建 "。
等待创建自动程序频道注册。 创建后,返回到 Azure 门户中的主页,然后选择" 自动程序服务"。 选择新的自动程序频道注册以查看其属性。
创建 Web 应用注册
返回到 Azure 门户的主页,然后选择"Azure Active Directory "。
选择“应用注册”。
选择“新注册”。 在“注册应用”页上,按如下方式设置值。
- 将“名称”设置为“
Graph Calendar Bot Auth
”。 - 将“受支持的帐户类型”设置为“任何组织目录中的帐户和个人 Microsoft 帐户”。
- 在“重定向 URI”下,将第一个下拉列表设置为“
Web
”,并将值设置为“https://token.botframework.com/.auth/web/redirect
”。
- 将“名称”设置为“
选择“注册”。 在"Graph日历 自动程序身份验证"页上,复制"应用程序 (客户端 ) ID"的值并 保存它,以下步骤中将需要该值。
选择“管理”下的“证书和密码”。 选择“新客户端密码”按钮。 在“说明”中输入值,并选择“过期”下的一个选项,再选择“添加”。
离开此页前,先复制客户端密码值。 你将在以下步骤中需要它。
选择 "API 权限",然后选择" 添加权限"。
选择 "microsoft Graph",然后选择"委派权限"。
选择以下权限,然后选择" 添加权限"。
- openid
- 个人资料
- Calendars.ReadWrite
- MailboxSettings.Read
关于权限
请考虑每个权限范围允许机器人执行哪些操作,以及机器人将使用这些权限范围执行哪些操作。
- openid 和 配置文件:允许机器人登录用户,并获取标识令牌Azure AD用户的基本信息。
- Calendars.ReadWrite:允许机器人读取用户的日历,以及向用户的日历添加新事件。
- MailboxSettings.Read:允许机器人读取用户的邮箱设置。 机器人将使用此密码获取用户的所选时区。
- User.Read:允许机器人从 Microsoft Graph。 机器人将使用此名称获取用户名。
向自动程序添加 OAuth 连接
导航到 Azure 门户中的自动程序 Azure 自动程序页面。 选择 "配置**"设置**。
选择 "添加 OAuth 连接设置"。
按如下所示填写表单,然后选择"保存 "。
- 名称:
GraphBotAuth
- 提供程序:Azure Active Directory v2
- 客户端 ID:日历自动程序身份验证 Graph应用程序 ID。
- 客户端密码:日历自动程序身份验证 Graph客户端 密码。
- 令牌Exchange URL:保留为空
- 租户 ID:
common
- 范围:
openid profile Calendars.ReadWrite MailboxSettings.Read User.Read
- 名称:
Select the GraphBotAuth entry under OAuth Connection 设置.
选择 "测试连接"。 这将打开一个新的浏览器窗口或选项卡以启动 OAuth 流。
如有必要,请登录。 查看请求的权限列表,然后选择"接受 "。
应看到"测试 到'GraphBotAuth'的连接成功" 消息。
提示
可以选择此 页上的"复制 令牌"按钮,然后将令牌粘贴到 https://jwt.ms 中以查看令牌中的声明。 当解决身份验证错误时,这非常有用。
添加 Microsoft identity platform 身份验证
在此练习中,你将使用 Bot Framework 的 OAuthPrompt 在自动程序中实现身份验证,并获取用于调用 Microsoft Graph API 的访问令牌。
打开 ./appsettings.json 并做出以下更改。
- 将 的值更改为你的
MicrosoftAppId
日历自动程序Graph 的应用程序 ID。 - 将 的值更改为
MicrosoftAppPassword
你的日历 Graph聊天 机器人客户端密码。 - 添加名为 的值
ConnectionName
,值为GraphBotAuth
。
{ "MicrosoftAppId": "YOUR_BOT_APP_ID_HERE", "MicrosoftAppPassword": "YOUR_BOT_CLIENT_SECRET_HERE", "ConnectionName": "GraphBotAuth" }
备注
如果你使用的值不用于
GraphBotAuth
Azure 门户中 OAuth Connection 设置的名称,请对条目使用ConnectionName
该值。- 将 的值更改为你的
实现对话框
在名为 Dialogs 的项目的根目录中新建 一个目录。 在 . /Dialogs 目录中创建一个名为 LogoutDialog.cs 的新文件并添加以下代码。
using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Schema; namespace CalendarBot.Dialogs { public class LogoutDialog : ComponentDialog { public LogoutDialog(string id, string connectionName) : base(id) { ConnectionName = connectionName; } protected string ConnectionName { get; private set; } // All dialogs should inherit this class so the user // can log out at any time protected override async Task<DialogTurnResult> OnBeginDialogAsync( DialogContext innerDc, object options, CancellationToken cancellationToken) { // Check if this is a logout command var result = await InterruptAsync(innerDc, cancellationToken); if (result != null) { return result; } return await base.OnBeginDialogAsync(innerDc, options, cancellationToken); } protected override async Task<DialogTurnResult> OnContinueDialogAsync( DialogContext innerDc, CancellationToken cancellationToken) { // Check if this is a logout command var result = await InterruptAsync(innerDc, cancellationToken); if (result != null) { return result; } return await base.OnContinueDialogAsync(innerDc, cancellationToken); } private async Task<DialogTurnResult> InterruptAsync( DialogContext innerDc, CancellationToken cancellationToken) { // If this is a logout command, cancel any other activities and log out if (innerDc.Context.Activity.Type == ActivityTypes.Message) { var text = innerDc.Context.Activity.Text.ToLowerInvariant(); if (text.StartsWith("log out") || text.StartsWith("logout")) { // The bot adapter encapsulates the authentication processes. var botAdapter = (BotFrameworkAdapter)innerDc.Context.Adapter; await botAdapter.SignOutUserAsync( innerDc.Context, ConnectionName, null, cancellationToken); await innerDc.Context.SendActivityAsync( MessageFactory.Text("You have been signed out."), cancellationToken); return await innerDc.CancelAllDialogsAsync(); } } return null; } } }
此对话框为自动程序中派生的所有其他对话框提供基类。 这允许用户注销,而不管他们在机器人对话框中的什么位置。
在名为 MainDialog.cs 的 ./Dialogs 目录中创建新文件并添加以下代码。
using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Dialogs.Choices; using Microsoft.Bot.Schema; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace CalendarBot.Dialogs { public class MainDialog : LogoutDialog { const string NO_PROMPT = "no-prompt"; protected readonly ILogger _logger; public MainDialog(IConfiguration configuration, ILogger<MainDialog> logger) : base(nameof(MainDialog), configuration["ConnectionName"]) { _logger = logger; // OAuthPrompt dialog handles the authentication and token // acquisition AddDialog(new OAuthPrompt( nameof(OAuthPrompt), new OAuthPromptSettings { ConnectionName = ConnectionName, Text = "Please login", Title = "Login", Timeout = 300000, // User has 5 minutes to login })); AddDialog(new ChoicePrompt(nameof(ChoicePrompt))); AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { LoginPromptStepAsync, ProcessLoginStepAsync, PromptUserStepAsync, CommandStepAsync, ProcessStepAsync, ReturnToPromptStepAsync })); // The initial child Dialog to run. InitialDialogId = nameof(WaterfallDialog); } private async Task<DialogTurnResult> LoginPromptStepAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { // If we're going through the waterfall a second time, don't do an extra OAuthPrompt var options = stepContext.Options?.ToString(); if (options == NO_PROMPT) { return await stepContext.NextAsync(cancellationToken: cancellationToken); } return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken); } private async Task<DialogTurnResult> ProcessLoginStepAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { // If we're going through the waterfall a second time, don't do an extra OAuthPrompt var options = stepContext.Options?.ToString(); if (options == NO_PROMPT) { return await stepContext.NextAsync(cancellationToken: cancellationToken); } // Get the token from the previous step. If it's there, login was successful if (stepContext.Result != null) { var tokenResponse = stepContext.Result as TokenResponse; if (!string.IsNullOrEmpty(tokenResponse?.Token)) { await stepContext.Context.SendActivityAsync( MessageFactory.Text("You are now logged in."), cancellationToken); return await stepContext.NextAsync(null, cancellationToken); } } await stepContext.Context.SendActivityAsync( MessageFactory.Text("Login was not successful please try again."), cancellationToken); return await stepContext.EndDialogAsync(); } private async Task<DialogTurnResult> PromptUserStepAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { var options = new PromptOptions { Prompt = MessageFactory.Text("Please choose an option below"), Choices = new List<Choice> { new Choice { Value = "Show token" }, new Choice { Value = "Show me" }, new Choice { Value = "Show calendar" }, new Choice { Value = "Add event" }, new Choice { Value = "Log out" }, } }; return await stepContext.PromptAsync( nameof(ChoicePrompt), options, cancellationToken); } private async Task<DialogTurnResult> CommandStepAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { // Save the command the user entered so we can get it back after // the OAuthPrompt completes var foundChoice = stepContext.Result as FoundChoice; // Result could be a FoundChoice (if user selected a choice button) // or a string (if user just typed something) stepContext.Values["command"] = foundChoice?.Value ?? stepContext.Result; // There is no reason to store the token locally in the bot because we can always just call // the OAuth prompt to get the token or get a new token if needed. The prompt completes silently // if the user is already signed in. return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken); } private async Task<DialogTurnResult> ProcessStepAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { if (stepContext.Result != null) { var tokenResponse = stepContext.Result as TokenResponse; // If we have the token use the user is authenticated so we may use it to make API calls. if (tokenResponse?.Token != null) { var command = ((string)stepContext.Values["command"] ?? string.Empty).ToLowerInvariant(); if (command.StartsWith("show token")) { // Show the user's token - for testing and troubleshooting // Generally production apps should not display access tokens await stepContext.Context.SendActivityAsync( MessageFactory.Text($"Your token is: {tokenResponse.Token}"), cancellationToken); } else if (command.StartsWith("show me")) { await stepContext.Context.SendActivityAsync( MessageFactory.Text("I don't know how to do this yet!"), cancellationToken); } else if (command.StartsWith("show calendar")) { await stepContext.Context.SendActivityAsync( MessageFactory.Text("I don't know how to do this yet!"), cancellationToken); } else if (command.StartsWith("add event")) { await stepContext.Context.SendActivityAsync( MessageFactory.Text("I don't know how to do this yet!"), cancellationToken); } else { await stepContext.Context.SendActivityAsync( MessageFactory.Text("I'm sorry, I didn't understand. Please try again."), cancellationToken); } } } else { await stepContext.Context.SendActivityAsync( MessageFactory.Text("We couldn't log you in. Please try again later."), cancellationToken); return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); } // Go to the next step return await stepContext.NextAsync(cancellationToken: cancellationToken); } private async Task<DialogTurnResult> ReturnToPromptStepAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { // Restart the dialog, but skip the initial login prompt return await stepContext.ReplaceDialogAsync(InitialDialogId, NO_PROMPT, cancellationToken); } } }
花些时间查看此代码。
- 在构造函数中,它使用一组按顺序排列的步骤设置 一个"瀑布 图"。
- In
LoginPromptStepAsync
it sends an OAuthPrompt. 如果用户未登录,这会向用户发送 UI 提示。 - In
ProcessLoginStepAsync
it checks if the login was successful and sends a confirmation. - In
PromptUserStepAsync
it sends a ChoicePrompt with the available commands. - In
CommandStepAsync
it saves the user's choice, then resends an OAuthPrompt. - In
ProcessStepAsync
it takes action based on the command received. - In
ReturnToPromptStepAsync
it starts the waterfall over, but passes a flag to skip the initial user login.
- In
- 在构造函数中,它使用一组按顺序排列的步骤设置 一个"瀑布 图"。
更新 CalendarBot
下一步是更新 CalendarBot 以使用这些新对话框。
打开 ./Bots/CalendarBot.cs ,并将其全部内容替换为以下代码。
using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Builder.Teams; using Microsoft.Bot.Schema; using Microsoft.Extensions.Logging; namespace CalendarBot.Bots { public class CalendarBot<T> : TeamsActivityHandler where T : Dialog { protected readonly BotState ConversationState; protected readonly Dialog Dialog; protected readonly ILogger Logger; protected readonly BotState UserState; public CalendarBot( ConversationState conversationState, UserState userState, T dialog, ILogger<CalendarBot<T>> logger) { ConversationState = conversationState; UserState = userState; Dialog = dialog; Logger = logger; } public override async Task OnTurnAsync( ITurnContext turnContext, CancellationToken cancellationToken = default(CancellationToken)) { Logger.LogInformation("CalendarBot.OnTurnAsync"); await base.OnTurnAsync(turnContext, cancellationToken); // Save any state changes that might have occurred during the turn. await ConversationState.SaveChangesAsync(turnContext, false, cancellationToken); await UserState.SaveChangesAsync(turnContext, false, cancellationToken); } protected override async Task OnMessageActivityAsync( ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken) { Logger.LogInformation("CalendarBot.OnMessageActivityAsync"); await Dialog.RunAsync(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken); } protected override async Task OnMembersAddedAsync( IList<ChannelAccount> membersAdded, ITurnContext<IConversationUpdateActivity> turnContext, CancellationToken cancellationToken) { Logger.LogInformation("CalendarBot.OnMembersAddedAsync"); var welcomeText = "Welcome to Microsoft Graph CalendarBot. Type anything to get started."; foreach (var member in membersAdded) { if (member.Id != turnContext.Activity.Recipient.Id) { await turnContext.SendActivityAsync( MessageFactory.Text(welcomeText), cancellationToken); } } } protected override async Task OnTokenResponseEventAsync( ITurnContext<IEventActivity> turnContext, CancellationToken cancellationToken) { Logger.LogInformation("CalendarBot.OnTokenResponseEventAsync"); await Dialog.RunAsync(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken); } protected override async Task OnTeamsSigninVerifyStateAsync( ITurnContext<IInvokeActivity> turnContext, CancellationToken cancellationToken) { Logger.LogInformation("CalendarBot.OnTeamsSigninVerifyStateAsync"); await Dialog.RunAsync(turnContext, ConversationState.CreateProperty<DialogState>(nameof(DialogState)), cancellationToken); } } }
以下是更改的简短摘要。
- 将 CalendarBot 类更改为模板类,以接收 Dialog。
- 更改 了 CalendarBot 类以扩展 TeamsActivityHandler,允许其登录 Microsoft Teams。
- 添加了其他方法替代以启用身份验证。
更新 Startup.cs
最后一步是更新 方法 ConfigureServices
,以添加身份验证所需的服务和新对话框。
打开 ./Startup.cs ,然后
services.AddTransient<IBot, Bots.CalendarBot>();
从 方法中删除ConfigureServices
行。在 方法的末尾插入以下
ConfigureServices
代码。// Create the storage we'll be using for User and Conversation state. (Memory is great for testing purposes.) services.AddSingleton<IStorage, MemoryStorage>(); // Create the User state. (Used in this bot's Dialog implementation.) services.AddSingleton<UserState>(); // Create the Conversation state. (Used by the Dialog system itself.) services.AddSingleton<ConversationState>(); // The Dialog that will be run by the bot. services.AddSingleton<Dialogs.MainDialog>(); // Create the bot as a transient. In this case the ASP Controller is expecting an IBot. services.AddTransient<IBot, Bots.CalendarBot<Dialogs.MainDialog>>();
测试身份验证
保存所有更改,然后使用 启动自动程序
dotnet run
。打开Bot Framework Emulator。 选择左⚙齿轮图标。
输入 ngrok 安装的本地路径,并启用本地地址旁路 ngrok 和运行 ngrok Emulator启动 选项。 选择“保存”。
选择" 文件" 菜单,然后选择" 新建自动程序配置..."。
填写字段,如下所示。
- 自动程序名称:
CalendarBot
- 终结点 URL:
https://localhost:3978/api/messages
- Microsoft 应用 ID: 日历自动程序Graph 的应用程序 ID
- **Microsoft 应用密码:**Graph日历自动程序 客户端密码
- 对存储在自动程序配置中的密钥进行加密: 已启用
- 自动程序名称:
选择 "保存并连接"。 在仿真器连接后,你应该会看到
Welcome to Microsoft Graph CalendarBot. Type anything to get started.
键入一些文本并将其发送到机器人。 机器人使用登录提示进行响应。
选择" 登录" 按钮。 仿真器会提示你确认以 开头的 URL
oauthlink://https://token.botframeworkcom
。 选择 "确认 "继续。在弹出窗口中,使用你的 Microsoft 365 帐户登录。 查看请求的权限并接受。
身份验证和同意完成后,弹出窗口将提供验证代码。 复制代码并关闭窗口。
在聊天窗口中输入验证代码以完成登录。
如果选择 "显示令牌 "按钮 (或键入
show token
) ,则自动程序将显示访问令牌。 " 注销" 按钮 (键入)log out
将注销。
提示
与机器人开始对话时,Bot Framework Emulator消息中出现以下错误消息。
Failed to generate an actual sign-in link: Error: Failed to connect to ngrok instance for OAuth postback URL:
FetchError: request to http://127.0.0.1:4041/api/tunnels failed, reason: connect ECONNREFUSED 127.0.0.1:4041
如果发生这种情况,请确保在仿真器设置中启用"Emulator启动时运行 ngrok"选项,然后 重新启动仿真器。
使用 Microsoft Graph 获取用户详细信息
在此部分中,你将使用 Microsoft Graph SDK 获取登录用户。
创建 Graph 服务
首先实现机器人可用于从 Microsoft Graph SDK 获取 GraphServiceClient 的服务,然后通过依赖关系注入使该服务对机器人可用。
在名为 Graph 的项目的 根目录中Graph。 在名为 IGraphClientService.cs 的 ./Graph 目录中创建新文件并添加以下代码。
using Microsoft.Graph; namespace CalendarBot.Graph { public interface IGraphClientService { GraphServiceClient GetAuthenticatedGraphClient(string accessToken); } }
在名为 GraphClientService.cs 的 ./Graph 目录中创建新文件并添加以下代码。
using Microsoft.Graph; using System.Net.Http.Headers; using System.Threading.Tasks; namespace CalendarBot.Graph { public class GraphClientService : IGraphClientService { public GraphServiceClient GetAuthenticatedGraphClient(string accessToken) { return new GraphServiceClient(new DelegateAuthenticationProvider( async (request) => { // Add the access token to the Authorization header // on the outgoing request request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); await Task.CompletedTask; } )); } } }
打开 ./Startup.cs ,在
using
文件顶部添加以下语句。using CalendarBot.Graph;
将以下代码添加到
ConfigureServices
函数末尾。// Add the Graph client service services.AddSingleton<IGraphClientService, GraphClientService>();
打开 ./Dialogs/MainDialog.cs。 将以下
using
语句添加到文件顶部。using System; using System.IO; using AdaptiveCards; using CalendarBot.Graph; using Microsoft.Graph;
将以下属性添加到 MainDialog 类。
private readonly IGraphClientService _graphClientService;
找到 MainDialog 类的构造函数, 并更新其签名以使用 IGraphServiceClient 参数。
public MainDialog( IConfiguration configuration, ILogger<MainDialog> logger, IGraphClientService graphClientService) : base(nameof(MainDialog), configuration["ConnectionName"])
将以下代码添加到构造函数。
_graphClientService = graphClientService;
获取登录的用户
在此部分中,你将使用 Microsoft Graph获取用户名、电子邮件地址和照片。 然后,你将创建自适应卡片以显示信息。
在名为 CardHelper.cs 的项目的根目录下创建一个新文件。 将以下代码添加到文件中。
using AdaptiveCards; using Microsoft.Graph; using System; using System.IO; namespace CalendarBot { public class CardHelper { public static AdaptiveCard GetUserCard(User user, Stream photo) { // Create an Adaptive Card to display the user // See https://adaptivecards.io/designer/ for possibilities var userCard = new AdaptiveCard("1.2"); var columns = new AdaptiveColumnSet(); userCard.Body.Add(columns); var userPhotoColumn = new AdaptiveColumn { Width = AdaptiveColumnWidth.Auto }; columns.Columns.Add(userPhotoColumn); userPhotoColumn.Items.Add(new AdaptiveImage { Style = AdaptiveImageStyle.Person, Size = AdaptiveImageSize.Small, Url = GetDataUriFromPhoto(photo) }); var userInfoColumn = new AdaptiveColumn {Width = AdaptiveColumnWidth.Stretch }; columns.Columns.Add(userInfoColumn); userInfoColumn.Items.Add(new AdaptiveTextBlock { Weight = AdaptiveTextWeight.Bolder, Wrap = true, Text = user.DisplayName }); userInfoColumn.Items.Add(new AdaptiveTextBlock { Spacing = AdaptiveSpacing.None, IsSubtle = true, Wrap = true, Text = user.Mail ?? user.UserPrincipalName }); return userCard; } private static Uri GetDataUriFromPhoto(Stream photo) { // Copy to a MemoryStream to get access to bytes var photoStream = new MemoryStream(); photo.CopyTo(photoStream); var photoBytes = photoStream.ToArray(); return new Uri($"data:image/png;base64,{Convert.ToBase64String(photoBytes)}"); } } }
此代码使用 AdaptiveCard NuGet 包生成自适应卡片来显示用户。
将以下函数添加到 MainDialog 类。
private async Task DisplayLoggedInUser( string accessToken, WaterfallStepContext stepContext, CancellationToken cancellationToken) { var graphClient = _graphClientService .GetAuthenticatedGraphClient(accessToken); // Get the user // GET /me?$select=displayName,mail,userPrincipalName var user = await graphClient.Me .Request() .Select(u => new { u.DisplayName, u.Mail, u.UserPrincipalName }) .GetAsync(); // Get the user's photo // GET /me/photos/48x48/$value var userPhoto = await graphClient.Me .Photos["48x48"] .Content .Request() .GetAsync(); // Generate an Adaptive Card var userCard = CardHelper.GetUserCard(user, userPhoto); // Create an attachment message to send the card var userMessage = MessageFactory.Attachment( new Microsoft.Bot.Schema.Attachment { ContentType = AdaptiveCard.ContentType, Content = userCard }); await stepContext.Context.SendActivityAsync(userMessage, cancellationToken); }
考虑此代码执行哪些功能。
- 它使用 graphClient 获取登录的用户。
- 它使用
Select
方法限制返回的字段。
- 它使用
- 它使用 graphClient 获取用户 的照片,请求受支持的最小大小为 48x48 像素。
- 它使用 CardHelper 类构造自适应卡片,并将该卡片作为附件发送。
- 它使用 graphClient 获取登录的用户。
将 中的 块
else if (command.StartsWith("show me"))
内的代码ProcessStepAsync
替换为以下内容。else if (command.StartsWith("show me")) { await DisplayLoggedInUser(tokenResponse.Token, stepContext, cancellationToken); }
保存所有更改并重新启动自动程序。
使用 Bot Framework Emulator连接到自动程序并登录。 选择 "显示我 "按钮以显示登录的用户。
获取日历视图
在此部分中,你将使用 Microsoft Graph SDK 获取用户日历中本周接下来的 3 个即将发生事件。
获取日历视图
日历视图是用户日历上两个日期/时间值之间的事件列表。 使用日历视图的优点是,它包括定期会议的任何事件。
打开 ./CardHelper.cs ,将以下函数添加到 CardHelper 类。
public static AdaptiveCard GetEventCard(Event calendarEvent, string dateTimeFormat) { // Build an Adaptive Card for the event var eventCard = new AdaptiveCard("1.2"); // Add subject as card title eventCard.Body.Add(new AdaptiveTextBlock { Size = AdaptiveTextSize.Medium, Weight = AdaptiveTextWeight.Bolder, Text = calendarEvent.Subject }); // Add organizer eventCard.Body.Add(new AdaptiveTextBlock { Size = AdaptiveTextSize.Default, Weight = AdaptiveTextWeight.Lighter, Spacing = AdaptiveSpacing.None, Text = calendarEvent.Organizer.EmailAddress.Name }); // Add details var details = new AdaptiveFactSet(); details.Facts.Add(new AdaptiveFact { Title = "Start", Value = DateTime.Parse(calendarEvent.Start.DateTime).ToString(dateTimeFormat) }); details.Facts.Add(new AdaptiveFact { Title = "End", Value = DateTime.Parse(calendarEvent.End.DateTime).ToString(dateTimeFormat) }); if (calendarEvent.Location != null && !string.IsNullOrEmpty(calendarEvent.Location.DisplayName)) { details.Facts.Add(new AdaptiveFact { Title = "Location", Value = calendarEvent.Location.DisplayName }); } eventCard.Body.Add(details); return eventCard; }
此代码生成自适应卡片以呈现日历事件。
打开 ./Dialogs/MainDialog.cs ,将以下函数添加到 MainDialog 类。
private async Task DisplayCalendarView( string accessToken, WaterfallStepContext stepContext, CancellationToken cancellationToken) { var graphClient = _graphClientService .GetAuthenticatedGraphClient(accessToken); // Get user's preferred time zone and format var user = await graphClient.Me .Request() .Select(u => new { u.MailboxSettings }) .GetAsync(); var dateTimeFormat = $"{user.MailboxSettings.DateFormat} {user.MailboxSettings.TimeFormat}"; if (string.IsNullOrWhiteSpace(dateTimeFormat)) { // Default to a standard format if user's preference not set dateTimeFormat = "G"; } var preferredTimeZone = user.MailboxSettings.TimeZone; var userTimeZone = TimeZoneInfo.FindSystemTimeZoneById(preferredTimeZone); var now = DateTime.UtcNow; // Calculate the end of the week (Sunday, midnight) int diff = 7 - (int)DateTime.Today.DayOfWeek; var weekEndUnspecified = DateTime.SpecifyKind( DateTime.Today.AddDays(diff), DateTimeKind.Unspecified); var endOfWeek = TimeZoneInfo.ConvertTimeToUtc(weekEndUnspecified, userTimeZone); // Set query parameters for the calendar view request var viewOptions = new List<QueryOption> { new QueryOption("startDateTime", now.ToString("o")), new QueryOption("endDateTime", endOfWeek.ToString("o")) }; // Get events happening between right now and the end of the week // GET /me/calendarView?startDateTime=""&endDateTime="" var events = await graphClient.Me .CalendarView .Request(viewOptions) // Send user time zone in request so date/time in // response will be in preferred time zone .Header("Prefer", $"outlook.timezone=\"{preferredTimeZone}\"") // Get max 3 per request .Top(3) // Only return fields app will use .Select(e => new { e.Subject, e.Organizer, e.Start, e.End, e.Location }) // Order results chronologically .OrderBy("start/dateTime") .GetAsync(); var calendarViewMessage = MessageFactory.Text("Here are your upcoming events"); calendarViewMessage.AttachmentLayout = AttachmentLayoutTypes.List; foreach(var calendarEvent in events.CurrentPage) { var eventCard = CardHelper.GetEventCard(calendarEvent, dateTimeFormat); // Add the card to the message's attachments calendarViewMessage.Attachments.Add(new Microsoft.Bot.Schema.Attachment { ContentType = AdaptiveCard.ContentType, Content = eventCard }); } await stepContext.Context.SendActivityAsync(calendarViewMessage, cancellationToken); }
考虑此代码执行哪些功能。
- 它获取用户的 MailboxSettings 以确定用户的首选时区和日期/时间格式。
- 它分别 将 startDateTime 和 endDateTime 值分别设置到每周的现在和结束。 这将定义日历视图使用的时段。
- 它通过
graphClient.Me.CalendarView
以下详细信息进行调用。- 它将标头
Prefer: outlook.timezone
设置为用户的首选时区,从而导致事件的开始时间和结束时间以用户时区表示。 - 它使用
Top(3)
方法将结果限制为仅前 3 个事件。 - 它使用
Select
将返回的字段限制为仅由自动程序使用的字段。 - 它使用
OrderBy
按开始时间对事件进行排序。
- 它将标头
- 它会向回复消息添加每个事件的自适应卡片。
将 中的 块
else if (command.StartsWith("show calendar"))
内的代码ProcessStepAsync
替换为以下内容。else if (command.StartsWith("show calendar")) { await DisplayCalendarView(tokenResponse.Token, stepContext, cancellationToken); }
保存所有更改并重新启动自动程序。
使用 Bot Framework Emulator连接到自动程序并登录。 选择" 显示日历 "按钮以显示日历视图。
创建新事件
在此部分中,你将使用 Microsoft Graph SDK 将事件添加到用户的日历。
实现对话框
首先,新建一个自定义对话框,提示用户输入将事件添加到其日历所需的值。 此对话框将使用 一个"一流"Dialog 执行以下步骤。
- 提示主题
- 询问用户是否要邀请人员
- 如果用户在上一步骤 (是,则提示与会者)
- 提示开始日期和时间
- 提示结束日期和时间
- 显示所有收集的值,并要求用户确认
- 如果用户确认,请从 OAuthPrompt 获取访问令牌
- 创建事件
在 . /Dialogs 目录中新建一个名为 NewEventDialog.cs 的文件并添加以下代码。
using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using CalendarBot.Graph; using Microsoft.Bot.Builder; using Microsoft.Bot.Builder.Dialogs; using Microsoft.Bot.Schema; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Microsoft.Graph; using Microsoft.Recognizers.Text.DataTypes.TimexExpression; using TimexTypes = Microsoft.Recognizers.Text.DataTypes.TimexExpression.Constants.TimexTypes; namespace CalendarBot.Dialogs { public class NewEventDialog : LogoutDialog { protected readonly ILogger _logger; private readonly IGraphClientService _graphClientService; public NewEventDialog( IConfiguration configuration, IGraphClientService graphClientService) : base(nameof(NewEventDialog), configuration["ConnectionName"]) { } // Generate a DateTime from the list of // DateTimeResolutions provided by the DateTimePrompt private static DateTime GetDateTimeFromResolutions(IList<DateTimeResolution> resolutions) { var timex = new TimexProperty(resolutions[0].Timex); // Handle the "now" case if (timex.Now ?? false) { return DateTime.Now; } // Otherwise generate a DateTime return TimexHelpers.DateFromTimex(timex); } } }
这是新对话框的 Shell,它将提示用户输入将事件添加到其日历所需的值。
将以下函数添加到 NewEventDialog 类以提示用户输入主题。
private async Task<DialogTurnResult> PromptForSubjectAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { return await stepContext.PromptAsync("subjectPrompt", new PromptOptions{ Prompt = MessageFactory.Text("What's the subject for your event?") }, cancellationToken); }
将以下函数添加到 NewEventDialog 类,以存储用户在上一步中提供的主题,并询问他们是否要添加与会者。
private async Task<DialogTurnResult> PromptForAddAttendeesAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { stepContext.Values["subject"] = (string)stepContext.Result; return await stepContext.PromptAsync(nameof(ConfirmPrompt), new PromptOptions{ Prompt = MessageFactory.Text("Do you want to invite other people to this event?") }, cancellationToken); }
将以下函数添加到 NewEventDialog 类,以检查用户上一步的响应,并提示用户输入与会者列表(如果需要)。
private async Task<DialogTurnResult> PromptForAttendeesAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { if ((bool)stepContext.Result) { // user wants to invite attendees // prompt for email addresses return await stepContext.PromptAsync("attendeesPrompt", new PromptOptions{ Prompt = MessageFactory.Text("Enter one or more email addresses of the people you want to invite. Separate multiple addresses with a semi-colon (;)."), RetryPrompt = MessageFactory.Text("One or more email addresses you entered are not valid. Please try again.") }, cancellationToken); } else { // Skip attendees prompt return await stepContext.NextAsync(null, cancellationToken); } }
将以下函数添加到 NewEventDialog 类,以存储上一步骤 ((如果存在)中的与会者列表) 并提示用户输入开始日期和时间。
private async Task<DialogTurnResult> PromptForStartAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { stepContext.Values["attendees"] = (string)stepContext.Result; return await stepContext.PromptAsync("startPrompt", new PromptOptions{ Prompt = MessageFactory.Text("When does the event start?"), RetryPrompt = MessageFactory.Text("I'm sorry, I didn't get that. Please provide both a day and a time.") }, cancellationToken); }
将以下函数添加到 NewEventDialog 类以存储上一步中的起始值,并提示用户输入结束日期和时间。
private async Task<DialogTurnResult> PromptForEndAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { var dateTimes = stepContext.Result as IList<DateTimeResolution>; var start = GetDateTimeFromResolutions(dateTimes); stepContext.Values["start"] = start; return await stepContext.PromptAsync("endPrompt", new PromptOptions{ Prompt = MessageFactory.Text("When does the event end?"), RetryPrompt = MessageFactory.Text("I'm sorry, I didn't get that. Please provide both a day and a time, and ensure that it is later than the start."), Validations = start }, cancellationToken); }
将以下函数添加到 NewEventDialog 类以存储上一步中的结束值,并要求用户确认所有输入。
private async Task<DialogTurnResult> ConfirmNewEventAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { var dateTimes = stepContext.Result as IList<DateTimeResolution>; var end = GetDateTimeFromResolutions(dateTimes); stepContext.Values["end"] = end; // Playback the values as we understand them var subject = stepContext.Values["subject"] as string; var attendees = stepContext.Values["attendees"] as string; var start = stepContext.Values["start"] as DateTime?; // Build a Markdown string var markdown = "Here's what I heard:\n\n"; markdown += $"- **Subject:** {subject}\n"; markdown += $"- **Attendees:** {attendees ?? "none"}\n"; markdown += $"- **Start:** {start?.ToString()}\n"; markdown += $"- **End:** {end.ToString()}"; await stepContext.Context.SendActivityAsync( MessageFactory.Text(markdown)); return await stepContext.PromptAsync(nameof(ConfirmPrompt), new PromptOptions { Prompt = MessageFactory.Text("Is this correct?") }, cancellationToken); }
将以下函数添加到 NewEventDialog 类以检查用户上一步的响应。 如果用户确认输入,请使用 OAuthPrompt 类获取访问令牌。 否则,请结束对话框。
private async Task<DialogTurnResult> GetTokenAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { if ((bool)stepContext.Result) { return await stepContext.BeginDialogAsync(nameof(OAuthPrompt), null, cancellationToken); } else { await stepContext.Context.SendActivityAsync( MessageFactory.Text("Please try again.")); return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); } }
将以下函数添加到 NewEventDialog 类,以使用 Microsoft Graph SDK 创建新事件。
private async Task<DialogTurnResult> AddEventAsync( WaterfallStepContext stepContext, CancellationToken cancellationToken) { if (stepContext.Result != null) { var tokenResponse = stepContext.Result as TokenResponse; if (tokenResponse?.Token != null) { var subject = stepContext.Values["subject"] as string; var attendees = stepContext.Values["attendees"] as string; var start = stepContext.Values["start"] as DateTime?; var end = stepContext.Values["end"] as DateTime?; // Get an authenticated Graph client using // the access token var graphClient = _graphClientService .GetAuthenticatedGraphClient(tokenResponse?.Token); try { // Get user's preferred time zone var user = await graphClient.Me .Request() .Select(u => new { u.MailboxSettings }) .GetAsync(); // Initialize an Event object var newEvent = new Event { Subject = subject, Start = new DateTimeTimeZone { DateTime = start?.ToString("o"), TimeZone = user.MailboxSettings.TimeZone }, End = new DateTimeTimeZone { DateTime = end?.ToString("o"), TimeZone = user.MailboxSettings.TimeZone } }; // If attendees were provided, add them if (!string.IsNullOrEmpty(attendees)) { // Initialize a list var attendeeList = new List<Attendee>(); // Split the string into an array var emails = attendees.Split(";"); foreach (var email in emails) { // Skip empty strings if (!string.IsNullOrEmpty(email)) { // Build a new Attendee object and // add to the list attendeeList.Add(new Attendee { Type = AttendeeType.Required, EmailAddress = new EmailAddress { Address = email } }); } } newEvent.Attendees = attendeeList; } // Add the event // POST /me/events await graphClient.Me .Events .Request() .AddAsync(newEvent); await stepContext.Context.SendActivityAsync( MessageFactory.Text("Event added"), cancellationToken); } catch (ServiceException ex) { _logger.LogError(ex, "Could not add event"); await stepContext.Context.SendActivityAsync( MessageFactory.Text("Something went wrong. Please try again."), cancellationToken); } return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); } } await stepContext.Context.SendActivityAsync( MessageFactory.Text("We couldn't log you in. Please try again later."), cancellationToken); return await stepContext.EndDialogAsync(cancellationToken: cancellationToken); }
考虑此代码执行哪些功能。
- 它获取用户的 MailboxSettings 以确定用户的首选时区。
- 它使用用户提供的值创建 Event 对象。 请注意, Start 和 End 属性是使用用户的时区设置的。
- 它在用户的日历上创建事件。
添加验证
现在,向用户输入添加验证,以避免在使用 Microsoft Graph 创建事件时出现错误。 我们希望确保:
- 如果用户提供了与会者列表,则该列表应是有效电子邮件地址的分号分隔列表。
- 开始日期/时间应为有效的日期和时间。
- 结束日期/时间应为有效的日期和时间,并且应晚于开始时间。
将以下函数添加到 NewEventDialog 类以验证用户的与会者条目。
private static Task<bool> AttendeesPromptValidatorAsync( PromptValidatorContext<string> promptContext, CancellationToken cancellationToken) { if (promptContext.Recognized.Succeeded) { // Check if these are emails var emails = promptContext.Recognized.Value.Split(";"); foreach (var email in emails) { // Skip empty entries if (string.IsNullOrEmpty(email)) { continue; } // If there's no '@' symbol it's invalid if (email.IndexOf('@') <= 0) { return Task.FromResult(false); } try { // Let the System.Net.Mail.MailAddress class // validate the rest. If invalid it will throw var mailAddress = new System.Net.Mail.MailAddress(email); if (mailAddress.Address != email) { return Task.FromResult(false); } } catch { return Task.FromResult(false); } } return Task.FromResult(true); } return Task.FromResult(false); }
将以下函数添加到 NewEventDialog 类以验证用户条目的开始日期和时间。
private static bool TimexHasDateAndTime(TimexProperty timex) { return timex.Now ?? false || (timex.Types.Contains(TimexTypes.DateTime) && timex.Types.Contains(TimexTypes.Definite)); } private static Task<bool> StartPromptValidatorAsync( PromptValidatorContext<IList<DateTimeResolution>> promptContext, CancellationToken cancellationToken) { if (promptContext.Recognized.Succeeded) { // Initialize a TimexProperty from the first // recognized value var timex = new TimexProperty( promptContext.Recognized.Value[0].Timex); // If it has a definite date and time, it's valid return Task.FromResult(TimexHasDateAndTime(timex)); } return Task.FromResult(false); }
将以下函数添加到 NewEventDialog 类以验证用户的结束日期和时间条目。
private static Task<bool> EndPromptValidatorAsync( PromptValidatorContext<IList<DateTimeResolution>> promptContext, CancellationToken cancellationToken) { if (promptContext.Recognized.Succeeded) { if (promptContext.Options.Validations is DateTime start) { // Initialize a TimexProperty from the first // recognized value var timex = new TimexProperty( promptContext.Recognized.Value[0].Timex); // Get the DateTime from this value to compare with start var end = GetDateTimeFromResolutions(promptContext.Recognized.Value); // If it has a definite date and time, and // the value is later than start, it's valid return Task.FromResult(TimexHasDateAndTime(timex) && DateTime.Compare(start, end) < 0); } } return Task.FromResult(false); }
向"瀑布""Dialog"添加步骤
现在,你已拥有该对话框的所有"步骤",最后一步是将它们添加到构造函数中的 一个"一流"Dialog 中,然后将 NewEventDialog 添加到 MainDialog。
找到 NewEventDialog 构造函数,并添加以下代码。
_graphClientService = graphClientService; // OAuthPrompt dialog handles the token // acquisition AddDialog(new OAuthPrompt( nameof(OAuthPrompt), new OAuthPromptSettings { ConnectionName = ConnectionName, Text = "Please login", Title = "Login", Timeout = 300000, // User has 5 minutes to login })); AddDialog(new TextPrompt("subjectPrompt")); // Validator ensures that the input is a semi-colon delimited // list of email addresses AddDialog(new TextPrompt("attendeesPrompt", AttendeesPromptValidatorAsync)); // Validator ensures that the input is a valid date and time AddDialog(new DateTimePrompt("startPrompt", StartPromptValidatorAsync)); // Validator ensures that the input is a valid date and time // and that it is later than the start AddDialog(new DateTimePrompt("endPrompt", EndPromptValidatorAsync)); AddDialog(new ConfirmPrompt(nameof(ConfirmPrompt))); AddDialog(new WaterfallDialog(nameof(WaterfallDialog), new WaterfallStep[] { PromptForSubjectAsync, PromptForAddAttendeesAsync, PromptForAttendeesAsync, PromptForStartAsync, PromptForEndAsync, ConfirmNewEventAsync, GetTokenAsync, AddEventAsync })); // The initial child Dialog to run. InitialDialogId = nameof(WaterfallDialog);
这将添加所有使用的对话框,并将你实现的所有函数添加为 "一个"在"瀑布"Dialog 中的步骤。
打开 ./Dialogs/MainDialog.cs ,然后向构造函数中添加以下行。
AddDialog(new NewEventDialog(configuration, graphClientService));
将 中的 块
else if (command.StartsWith("add event"))
内的代码ProcessStepAsync
替换为以下内容。else if (command.StartsWith("add event")) { return await stepContext.BeginDialogAsync(nameof(NewEventDialog), null, cancellationToken); }
保存所有更改并重新启动自动程序。
使用 Bot Framework Emulator连接到自动程序并登录。 选择" 添加事件" 按钮。
响应提示以创建新事件。 请注意,当提示您输入 start 和 end 值时,您可以使用类似"today at 3PM"或"now"的短语。
恭喜!
你已完成 Bot Framework Microsoft Graph教程。 现在,你已经拥有一个调用 Microsoft Graph,可以试验并添加新功能。 请访问 Microsoft Graph概述,查看可以使用 Microsoft Graph 访问的所有数据。
反馈
Please provide any feedback on this tutorial in the GitHub repository.
你有关于此部分的问题? 如果有,请向我们提供反馈,以便我们对此部分作出改进。