实现技能使用者

适用于:SDK v4

可以使用技能扩展其他机器人。 技能是可以为另一机器人执行一组任务的机器人,使用清单来描述其接口。 根机器人是面向用户的机器人,可调用一项或多项技能。 根机器人是一种技能使用者。

  • 技能使用者可以使用声明验证来管理哪些技能可以访问它。
  • 技能使用者可以使用多项技能。
  • 无权访问技能源代码的开发人员可以使用技能清单中的信息来设计其技能使用者。

本文演示如何实现一个使用回显技能来回显用户输入的技能使用者。 如需示例技能清单并了解如何实现回显技能,请参阅如何实现技能

有关如何通过技能对话使用技能的信息,请参阅如何通过对话使用技能

某些类型的技能使用者无法使用某些类型的技能机器人。 下表介绍支持的组合方式。

  多租户技能 单租户技能 用户分配的托管标识技能
多租户使用者 支持 不支持 不支持
单租户使用者 不支持 如果两个应用都属于同一租户,则受支持 如果两个应用都属于同一租户,则受支持
用户分配的托管标识使用者 不支持 如果两个应用都属于同一租户,则受支持 如果两个应用都属于同一租户,则受支持

注意

Bot Framework JavaScript、C# 和 Python SDK 将继续受支持,但 Java SDK 即将停用,最终长期支持将于 2023 年 11 月结束。

使用 Java SDK 构建的现有机器人将继续正常运行。

要生成新的机器人,请考虑使用 Microsoft Copilot Studio 并阅读选择正确的助理解决方案

有关详细信息,请参阅机器人构建的未来

先决条件

注意

从版本 4.11 开始,在 Bot Framework Emulator 中以本地方式测试技能使用者不需要应用 ID 和密码。 将使用者部署到 Azure 或使用部署的技能仍需要 Azure 订阅。

关于此示例

skills-simple-bot-to-bot 示例包含下面的两个机器人的项目:

  • 用于实现此技能的回显技能机器人。
  • 简单根机器人,其实现的根机器人使用此技能。

本文重点介绍根机器人,其中包括其机器人和适配器对象中的支持逻辑,并包括用于与技能交换活动的对象。 其中包括:

  • 用于向技能发送活动的技能客户端。
  • 用于从技能接收活动的技能处理程序。
  • 技能聊天 ID 工厂,供技能客户端和处理程序用来在用户-根聊天引用和根-技能聊天引用之间进行转换。

有关回显技能机器人的信息,请参阅如何实现技能

资源

对于部署的机器人,机器人到机器人身份验证要求每个参与的机器人都有有效的标识信息。 但是,无需应用 ID 和密码,即可在本地使用 Emulator 测试多租户技能和技能使用者。

应用程序配置

  1. (可选)将根机器人的标识信息添加到其配置文件中。 如果技能或技能使用者提供标识信息,则两者都必须提供。
  2. 添加技能主机终结点(服务或回调 URL),技能应将该主机回复给技能使用者。
  3. 针对技能使用者将要使用的每项技能添加一个条目。 每个条目包含:
    • 一个 ID,供技能使用者用来标识每项技能。
    • (可选)技能的应用或客户端 ID。
    • 技能的消息传送终结点。

注意

如果技能或技能使用者提供标识信息,则两者都必须提供。

SimpleRootBot\appsettings.json

(可选)添加根机器人的标识信息,并为回显技能机器人添加应用或客户端 ID。

{
  "MicrosoftAppType": "",
  "MicrosoftAppId": "",
  "MicrosoftAppPassword": "",
  "MicrosoftAppTenantId": "",
  "SkillHostEndpoint": "http://localhost:3978/api/skills/",
  "BotFrameworkSkills": [
    {
      "Id": "EchoSkillBot",
      "AppId": "",
      "SkillEndpoint": "http://localhost:39783/api/messages"
    }
  ]
}

技能配置

此示例将配置文件中每项技能的信息读取到一组 skill 对象中。

SimpleRootBot\SkillsConfiguration.cs

public class SkillsConfiguration
{
    public SkillsConfiguration(IConfiguration configuration)
    {
        var section = configuration?.GetSection("BotFrameworkSkills");
        var skills = section?.Get<BotFrameworkSkill[]>();
        if (skills != null)
        {
            foreach (var skill in skills)
            {
                Skills.Add(skill.Id, skill);
            }
        }

        var skillHostEndpoint = configuration?.GetValue<string>(nameof(SkillHostEndpoint));
        if (!string.IsNullOrWhiteSpace(skillHostEndpoint))
        {
            SkillHostEndpoint = new Uri(skillHostEndpoint);
        }
    }

    public Uri SkillHostEndpoint { get; }

    public Dictionary<string, BotFrameworkSkill> Skills { get; } = new Dictionary<string, BotFrameworkSkill>();
}

聊天 ID 工厂

这会创建与技能配合使用的聊天 ID,并可从技能聊天 ID 恢复原始用户聊天 ID。

此示例的聊天 ID 工厂支持一个简单的方案,其中:

  • 根机器人旨在使用一种特定的技能。
  • 根机器人一次只能与一项技能进行一项活动的聊天。

SDK 提供的 SkillConversationIdFactory 类可跨任何技能使用,无需复制源代码。 对话 ID 中心在 Startup.cs 中配置。

若要支持更复杂的方案,请在设计聊天 ID 工厂时确保:

  • “创建技能聊天 ID”方法获取或生成相应的技能聊天 ID。
  • “获取聊天引用”方法获取正确的用户聊天。

技能客户端和技能处理程序

技能使用者使用技能客户端向技能转发活动。 客户端使用技能配置信息和聊天 ID 工厂来完成此操作。

技能使用者使用技能处理程序从技能接收活动。 处理程序使用聊天 ID 工厂、身份验证配置和凭据提供程序来执行此操作,并且还有对根机器人的适配器和活动处理程序的依赖性

SimpleRootBot\Startup.cs

services.AddSingleton<IBotFrameworkHttpAdapter>(sp => sp.GetService<CloudAdapter>());
services.AddSingleton<BotAdapter>(sp => sp.GetService<CloudAdapter>());

来自技能的 HTTP 流量会进入技能使用者播发到技能的服务 URL 端点。 使用特定于语言的终结点处理程序将流量转发到技能处理程序。

默认技能处理程序执行以下操作:

  • 如果提供了应用 ID 和密码,则使用身份验证配置对象执行机器人到机器人身份验证和声明验证。
  • 使用聊天 ID 工厂从使用者-技能聊天转换回根-用户聊天。
  • 生成一条主动消息,使技能使用者可以重新建立根-用户轮次的上下文并向用户转发活动。

活动处理程序逻辑

请注意,技能使用者逻辑应执行以下操作:

  • 记住是否有任何活动的技能,并根据需要向它们转发活动。
  • 注意用户何时提出某个应转发给技能的请求,并启动技能。
  • 从任何活动技能中查找 endOfConversation 活动,这样就可以注意到该活动何时完成。
  • 根据需要添加逻辑,让用户或技能使用者取消尚未完成的技能。
  • 在对技能进行调用之前保存状态,因为任何响应都可能返回到技能使用者的另一实例。

SimpleRootBot\Bots\RootBot.cs

根机器人依赖于聊天状态、技能信息、技能客户端和常规配置。 ASP.NET 通过依赖项注入提供这些对象。 根机器人还定义了一个聊天状态属性访问器,对哪项技能处于活动状态进行跟踪。

public static readonly string ActiveSkillPropertyName = $"{typeof(RootBot).FullName}.ActiveSkillProperty";
private readonly IStatePropertyAccessor<BotFrameworkSkill> _activeSkillProperty;
private readonly string _botId;
private readonly ConversationState _conversationState;
private readonly BotFrameworkAuthentication _auth;
private readonly SkillConversationIdFactoryBase _conversationIdFactory;
private readonly SkillsConfiguration _skillsConfig;
private readonly BotFrameworkSkill _targetSkill;

public RootBot(BotFrameworkAuthentication auth, ConversationState conversationState, SkillsConfiguration skillsConfig, SkillConversationIdFactoryBase conversationIdFactory, IConfiguration configuration)
{
    _auth = auth ?? throw new ArgumentNullException(nameof(auth));
    _conversationState = conversationState ?? throw new ArgumentNullException(nameof(conversationState));
    _skillsConfig = skillsConfig ?? throw new ArgumentNullException(nameof(skillsConfig));
    _conversationIdFactory = conversationIdFactory ?? throw new ArgumentNullException(nameof(conversationIdFactory));

    if (configuration == null)
    {
        throw new ArgumentNullException(nameof(configuration));
    }

    _botId = configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value;

    // We use a single skill in this example.
    var targetSkillId = "EchoSkillBot";
    _skillsConfig.Skills.TryGetValue(targetSkillId, out _targetSkill);

    // Create state property to track the active skill
    _activeSkillProperty = conversationState.CreateProperty<BotFrameworkSkill>(ActiveSkillPropertyName);
}

此示例有一个帮助程序方法,用于将活动转发给技能。 它在调用技能之前保存聊天状态,并检查 HTTP 请求是否成功。

private async Task SendToSkill(ITurnContext turnContext, BotFrameworkSkill targetSkill, CancellationToken cancellationToken)
{
    // NOTE: Always SaveChanges() before calling a skill so that any activity generated by the skill
    // will have access to current accurate state.
    await _conversationState.SaveChangesAsync(turnContext, force: true, cancellationToken: cancellationToken);

    // Create a conversationId to interact with the skill and send the activity
    var options = new SkillConversationIdFactoryOptions
    {
        FromBotOAuthScope = turnContext.TurnState.Get<string>(BotAdapter.OAuthScopeKey),
        FromBotId = _botId,
        Activity = turnContext.Activity,
        BotFrameworkSkill = targetSkill
    };
    var skillConversationId = await _conversationIdFactory.CreateSkillConversationIdAsync(options, cancellationToken);

    using var client = _auth.CreateBotFrameworkClient();

    // route the activity to the skill
    var response = await client.PostActivityAsync(_botId, targetSkill.AppId, targetSkill.SkillEndpoint, _skillsConfig.SkillHostEndpoint, skillConversationId, turnContext.Activity, cancellationToken);

    // Check response status
    if (!(response.Status >= 200 && response.Status <= 299))
    {
        throw new HttpRequestException($"Error invoking the skill id: \"{targetSkill.Id}\" at \"{targetSkill.SkillEndpoint}\" (status is {response.Status}). \r\n {response.Body}");
    }
}

需要注意的是,根机器人包含的逻辑可用于将活动转发到技能、在用户请求时启动技能,以及在技能完成时停止技能。

protected override async Task OnMessageActivityAsync(ITurnContext<IMessageActivity> turnContext, CancellationToken cancellationToken)
{
    if (turnContext.Activity.Text.Contains("skill"))
    {
        await turnContext.SendActivityAsync(MessageFactory.Text("Got it, connecting you to the skill..."), cancellationToken);

        // Save active skill in state
        await _activeSkillProperty.SetAsync(turnContext, _targetSkill, cancellationToken);

        // Send the activity to the skill
        await SendToSkill(turnContext, _targetSkill, cancellationToken);
        return;
    }

    // just respond
    await turnContext.SendActivityAsync(MessageFactory.Text("Me no nothin'. Say \"skill\" and I'll patch you through"), cancellationToken);

    // Save conversation state
    await _conversationState.SaveChangesAsync(turnContext, force: true, cancellationToken: cancellationToken);
}

protected override async Task OnEndOfConversationActivityAsync(ITurnContext<IEndOfConversationActivity> turnContext, CancellationToken cancellationToken)
{
    // forget skill invocation
    await _activeSkillProperty.DeleteAsync(turnContext, cancellationToken);

    // Show status message, text and value returned by the skill
    var eocActivityMessage = $"Received {ActivityTypes.EndOfConversation}.\n\nCode: {turnContext.Activity.Code}";
    if (!string.IsNullOrWhiteSpace(turnContext.Activity.Text))
    {
        eocActivityMessage += $"\n\nText: {turnContext.Activity.Text}";
    }

    if ((turnContext.Activity as Activity)?.Value != null)
    {
        eocActivityMessage += $"\n\nValue: {JsonConvert.SerializeObject((turnContext.Activity as Activity)?.Value)}";
    }

    await turnContext.SendActivityAsync(MessageFactory.Text(eocActivityMessage), cancellationToken);

    // We are back at the root
    await turnContext.SendActivityAsync(MessageFactory.Text("Back in the root bot. Say \"skill\" and I'll patch you through"), cancellationToken);

    // Save conversation state
    await _conversationState.SaveChangesAsync(turnContext, cancellationToken: cancellationToken);
}

轮次错误处理程序

出现错误时,适配器会清除聊天状态以重置与用户的聊天,避免保持错误状态。

在技能使用者中清除对话状态之前,最好将“聊天结束”活动发送给任何活动技能。 这会使技能在技能使用者释放聊天之前释放与使用者-技能聊天相关联的任何资源。

SimpleRootBot\AdapterWithErrorHandler.cs

在此示例中,轮次错误逻辑在几个帮助程序方法中进行拆分。

private async Task HandleTurnError(ITurnContext turnContext, Exception exception)
{
    // Log any leaked exception from the application.
    // NOTE: In production environment, you should consider logging this to
    // Azure Application Insights. Visit https://aka.ms/bottelemetry to see how
    // to add telemetry capture to your bot.
    _logger.LogError(exception, $"[OnTurnError] unhandled error : {exception.Message}");

    await SendErrorMessageAsync(turnContext, exception);
    await EndSkillConversationAsync(turnContext);
    await ClearConversationStateAsync(turnContext);
}

private async Task SendErrorMessageAsync(ITurnContext turnContext, Exception exception)
{
    try
    {
        // Send a message to the user
        var errorMessageText = "The bot encountered an error or bug.";
        var errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.IgnoringInput);
        await turnContext.SendActivityAsync(errorMessage);

        errorMessageText = "To continue to run this bot, please fix the bot source code.";
        errorMessage = MessageFactory.Text(errorMessageText, errorMessageText, InputHints.ExpectingInput);
        await turnContext.SendActivityAsync(errorMessage);

        // Send a trace activity, which will be displayed in the Bot Framework Emulator
        await turnContext.TraceActivityAsync("OnTurnError Trace", exception.ToString(), "https://www.botframework.com/schemas/error", "TurnError");
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, $"Exception caught in SendErrorMessageAsync : {ex}");
    }
}

private async Task EndSkillConversationAsync(ITurnContext turnContext)
{
    if (_skillsConfig == null)
    {
        return;
    }

    try
    {
        // Inform the active skill that the conversation is ended so that it has
        // a chance to clean up.
        // Note: ActiveSkillPropertyName is set by the RooBot while messages are being
        // forwarded to a Skill.
        var activeSkill = await _conversationState.CreateProperty<BotFrameworkSkill>(RootBot.ActiveSkillPropertyName).GetAsync(turnContext, () => null);
        if (activeSkill != null)
        {
            var botId = _configuration.GetSection(MicrosoftAppCredentials.MicrosoftAppIdKey)?.Value;

            var endOfConversation = Activity.CreateEndOfConversationActivity();
            endOfConversation.Code = "RootSkillError";
            endOfConversation.ApplyConversationReference(turnContext.Activity.GetConversationReference(), true);

            await _conversationState.SaveChangesAsync(turnContext, true);

            using var client = _auth.CreateBotFrameworkClient();

            await client.PostActivityAsync(botId, activeSkill.AppId, activeSkill.SkillEndpoint, _skillsConfig.SkillHostEndpoint, endOfConversation.Conversation.Id, (Activity)endOfConversation, CancellationToken.None);
        }
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, $"Exception caught on attempting to send EndOfConversation : {ex}");
    }
}

private async Task ClearConversationStateAsync(ITurnContext turnContext)
{
    try
    {
        // Delete the conversationState for the current conversation to prevent the
        // bot from getting stuck in a error-loop caused by being in a bad state.
        // ConversationState should be thought of as similar to "cookie-state" in a Web pages.
        await _conversationState.DeleteAsync(turnContext);
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, $"Exception caught on attempting to Delete ConversationState : {ex}");
    }
}

技能终结点

机器人定义一个终结点,该终结点将传入技能活动转发到根机器人的技能处理程序。

SimpleRootBot\Controllers\SkillController.cs

[ApiController]
[Route("api/skills")]
public class SkillController : ChannelServiceController
{
    public SkillController(ChannelServiceHandlerBase handler)
        : base(handler)
    {
    }
}

服务注册

包括具有任何声明验证的身份验证配置对象,以及所有其他对象。 此示例使用相同的身份验证配置逻辑来验证用户和技能中的活动。

SimpleRootBot\Startup.cs

// Register the skills configuration class
services.AddSingleton<SkillsConfiguration>();

// Register AuthConfiguration to enable custom claim validation.
services.AddSingleton(sp =>
{
    var allowedSkills = sp.GetService<SkillsConfiguration>().Skills.Values.Select(s => s.AppId).ToList();

    var claimsValidator = new AllowedSkillsClaimsValidator(allowedSkills);

    // If TenantId is specified in config, add the tenant as a valid JWT token issuer for Bot to Skill conversation.
    // The token issuer for MSI and single tenant scenarios will be the tenant where the bot is registered.
    var validTokenIssuers = new List<string>();
    var tenantId = sp.GetService<IConfiguration>().GetSection(MicrosoftAppCredentials.MicrosoftAppTenantIdKey)?.Value;

    if (!string.IsNullOrWhiteSpace(tenantId))
    {
        // For SingleTenant/MSI auth, the JWT tokens will be issued from the bot's home tenant.
        // Therefore, these issuers need to be added to the list of valid token issuers for authenticating activity requests.
        validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, tenantId));
        validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, tenantId));
        validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidGovernmentTokenIssuerUrlTemplateV1, tenantId));
        validTokenIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidGovernmentTokenIssuerUrlTemplateV2, tenantId));
    }

    return new AuthenticationConfiguration
    {
        ClaimsValidator = claimsValidator,
        ValidTokenIssuers = validTokenIssuers
    };
});

测试根机器人

可以在 Emulator 中测试技能使用者,就像它是普通机器人一样;但是,你需要同时运行技能和技能使用者机器人。 请参阅如何实现技能,了解如何配置技能。

下载并安装最新的 Bot Framework Emulator

  1. 在计算机上以本地方式运行回显技能机器人和简单的根机器人。 如需说明,请参阅 C#JavaScriptJavaPython 示例的 README 文件。
  2. 使用 Emulator 测试机器人,如下所示。 向技能发送 endstop 消息时,该技能还会向根机器人发送除回复消息以外的 endOfConversation 活动。 endOfConversation 活动的 code 属性指示技能已成功完成。

与技能使用者交互的示例脚本。

有关调试的更多信息

由于技能与技能使用者之间的流量已经过身份验证,因此调试此类机器人时会执行额外的步骤。

  • 技能使用者及其直接或间接使用的所有技能都必须处于运行中。
  • 如果机器人在本地运行,并且任何机器人有应用 ID 和密码,则所有机器人都必须具有有效的 ID 和密码。
  • 如果所有机器人都已部署,请参阅如何使用 devtunnel 从任何通道调试机器人
  • 如果某些机器人在本地运行,并且部署了一些机器人,请参阅如何调试技能或技能使用者

或者,可以像调试其他机器人一样调试技能使用者或技能。 有关详细信息,请参阅调试机器人使用 Bot Framework Emulator 执行调试

其他信息

下面是实现更复杂的根机器人时要考虑的一些事项。

允许用户取消多步技能

根机器人在将用户的消息转发给活动技能之前,应对该消息进行检查。 如果用户想要取消当前进程,根机器人可以向技能发送 endOfConversation 活动,而不是转发消息。

在根和技能机器人之间交换数据

若要将参数发送给技能,技能使用者可以在其发送给技能的消息上设置 value 属性。 若要从技能接收返回值,技能使用者应在技能发送 endOfConversation 活动时检查 value 属性。

使用多项技能

  • 如果某项技能处于活动状态,则根机器人需要确定哪项技能处于活动状态,然后将用户的消息转发给正确的技能。
  • 如果没有技能处于活动状态,则根机器人需要根据机器人状态和用户输入来确定要启动的技能(如果有)。
  • 如果希望允许用户在多个并发技能之间切换,则根机器人在转发用户的消息之前需确定用户要与哪项活动技能交互。

使用预期回复的传递模式

若要使用预期答复传递模式:

  • 从 turn 上下文克隆活动。
  • 在将活动从根机器人发送到技能之前,将新活动的"传递模式"属性设置为 "ExpectReplies"。
  • 从请求响应返回的调用响应正文中读取预期的答复
  • 在根机器人中处理每个活动,或将其发送到启动原始请求的通道。

如果对活动进行答复的机器人需要是接收活动的机器人的相同实例,则预期回复会很有用。