Implement Custom Components

To implement custom components, you use the Oracle Digital Assistant Node.js SDK to interface with Digital Assistant's custom component service.

Here's how to implement custom components that you can deploy to the Digital Assistant embedded container, Oracle Cloud Infrastructure Functions, a Mobile Hub backend, or a Node.js server:

  1. Install the software for building custom components.

  2. Create the custom component package.

  3. Create and build a custom component.

Note

If you plan to deploy the custom component package to an embedded custom component service, each skill that you add the package to is counted as a separate service. There's a limit to how many embedded custom component services an instance can have. If you don't know the limit, ask your service administrator to get the embedded-custom-component-service-count for you as described in View Service Limits in the Infrastructure Console. Consider packaging several components per package to minimize the number of embedded component services that you use. If you try to add a component service after you meet that limit, the service creation fails.

Step 1: Install the Software for Building Custom Components

To build a custom component package, you need Node.js, Node Package Manager, and the Oracle Digital Assistant Bots Node.js SDK.

Note

On Windows, the Bots Node SDK doesn't work on Windows if the Node installation is version 20.12.2 or higher because of a backward-incompatible change in Node.js. If you already have Node version 20.12.2 or higher installed, you need to uninstall it and then install version 20.12.1 or earlier version for the Bots Node SDK to work.
  1. If you haven’t already, download Node.js from https://nodejs.org and install it for global access. Node Package Manager (npm) is distributed with Node.js.

    To test if Node.js and npm are installed, open a terminal window and type these commands:

    node –v 
    npm –v
  2. To install the Oracle Digital Assistant Bots Node.js SDK for global access, enter this command in a terminal window:
    npm install -g @oracle/bots-node-sdk

    On a Mac, you use the sudo command:

    sudo npm install -g @oracle/bots-node-sdk

    When you use the -g (global) option, you have direct access to the bots-node-sdk command line interface. Otherwise, use npx @oracle/bots-node-sdk.

  3. To verify your Oracle Digital Assistant Bots Node.js SDK installation, type the following command:
    bots-node-sdk -v
    The command should print the Oracle Digital Assistant Bots Node.js SDK version.

Step 2: Create the Custom Component Package

To start a project, you use the bots-node-sdk init command from the SDK’s command line interface (CLI) to create the necessary files and directory structure for your component structure.

The init command has a few options, such as whether to use JavaScript (the default) or TypeScript, and what to name the initial component's JavaScript file. These options are described in CLI Developer Tools. Here's the basic command for starting a JavaScript project:

bots-node-sdk init <top-level folder path> --name <component service name>

This command completes the following actions for a JavaScript package:

  • Creates the top-level folder.

  • Creates a components folder and adds a sample component JavaScript file named hello.world.js. This is where you'll put your component JavaScript files.

  • Adds a package.json file, which specifies main.js as the main entry point and lists @oracle/bots-node-sdk as a devDependency. The package file also points to some bots-node-sdk scripts.

    {
      "name": "myCustomComponentService",
      "version": "1.0.0",
      "description": "Oracle Bots Custom Component Package",
      "main": "main.js",
      "scripts": {
        "bots-node-sdk": "bots-node-sdk",
        "help": "npm run bots-node-sdk -- --help",
        "prepack": "npm run bots-node-sdk -- pack --dry-run",
        "start": "npm run bots-node-sdk -- service ."
      },
      "repository": {},
      "dependencies": {},
      "devDependencies": {
        "@oracle/bots-node-sdk": "^2.2.2",
        "express": "^4.16.3"
      }
    }
  • Adds a main.js file, which exports the package settings and points to the components folder for the location of the components, to the top-level folder.

  • Adds an .npmignore file to the top-level folder. This file is used when you export the component package. It must exclude .tgz files from the package. For example: *.tgz.

  • For some versions of npm, creates a package-lock.json file.

  • Installs all package dependencies into the node_modules subfolder.
Note

If you don't use the bots-node-sdk init command to create the package folder, then ensure that the top-level folder contains an .npmignore file that contains a *.tgz entry. For example:
*.tgz
spec
service-*

Otherwise, every time you pack the files into a TGZ file, you include the TGZ file that already exists in the top-level folder, and your TGZ file will continue to double in size.

If you plan to deploy to the embedded container, your package should be compatible with Node 14.17.0.

Step 3: Create and Build a Custom Component

Create the Component File

Use the SDK's CLI init component command to create a JavaScript or TypeScript file with the framework for working with the Oracle Digital Assistant Node.js SDK to write a custom component. The language that you specified when you ran the init command to create the component package determines whether a JavaScript or a TypeScript file is created.

For example, to create a file for the custom component, from a terminal window, CD to the package’s top-level folder and type the following command, replacing <component name> with your component's name:

bots-node-sdk init component <component name> c components

For JavaScript, this command adds the <component name>.js to the components folder. For TypeScript, the file is added to the src/components folder. The c argument indicates that the file is for a custom component.

Note that the component name can't exceed 100 characters. You can only use alphanumeric characters and underscores in the name. You can't use hyphens. Nor can the name have a System. prefix. Oracle Digital Assistant won't allow you to add a custom component service that has invalid component names.

For further details, see https://github.com/oracle/bots-node-sdk/blob/master/bin/CLI.md.

Add Code to the metadata and invoke Functions

Your custom component must export two objects:

  • metadata: This provides the following component information to the skill.
    • Component name
    • Supported properties
    • Supported transition actions

    For YAML-based dialog flows, the custom component supports the following properties by default. These properties aren't available for skills designed in Visual dialog mode.

    • autoNumberPostbackActions: Boolean. Not required. When true, buttons and list options are numbered automatically. The default is false. See Auto-Numbering for Text-Only Channels in YAML Dialog Flows.
    • insightsEndConversation: Boolean. Not required. When true, the session stops recording the conversation for insights reporting. The default is false. See Model the Dialog Flow.
    • insightsInclude: Boolean. Not required. When true, the state is included in insights reporting. The default is true. See Model the Dialog Flow.
    • translate: Boolean. Not required. When true, autotranslation is enabled for this component. The default is the value of the autotranslation context variable. See Translation Services in Skills.
  • invoke: This contains the logic to execute. In this method, you can read and write skill context variables, create conversation messages, set state transitions, make REST calls, and more. Typically, you would use the async keyword with this function to handle promises. The invoke function takes the following argument:
    • context, which names the reference to the CustomComponentContext object in the Digital Assistant Node.js SDK. This class is described in the SDK documentation at https://oracle.github.io/bots-node-sdk/. In earlier versions of the SDK, the name was conversation. You can use either name.
    Note

    If you are using a JavaScript library that doesn't support promises (and thus aren't using async keyword), it is also possible to add a done argument as a callback that the component invokes when it has finished processing.

Here’s an example:

'use strict';

module.exports = {

  metadata: {
    name: 'helloWorld',
    properties: {
      human: { required: true, type: 'string' }
    },
    supportedActions: ['weekday', 'weekend']
  },

  invoke: async(context) => {
    // Retrieve the value of the 'human' component property.
    const { human } = context.properties();
    // determine date
    const now = new Date();
    const dayOfWeek = now.toLocaleDateString('en-US', { weekday: 'long' });
    const isWeekend = [0, 6].indexOf(now.getDay()) > -1;
    // Send two messages, and transition based on the day of the week
    context.reply(`Greetings ${human}`)
      .reply(`Today is ${now.toLocaleDateString()}, a ${dayOfWeek}`)
      .transition(isWeekend ? 'weekend' : 'weekday');   
  }
}

To learn more and explore some code examples, see Writing Custom Components in the Bots Node SDK documentation.

Control the Flow with keepTurn and transition

You use different combinations of the Bots Node SDK keepTurn and transition functions to define how the custom component interacts with a user and how the conversation continues after the component returns flow control to the skill.

  • keepTurn(boolean) specifies whether the conversation should transition to another state without first prompting for user input.

    Note that if you want to set keepTurn to true, you should call keepTurn after you call reply because reply implicitly sets keepTurn to false.

  • transition(action) causes the dialog to transition to the next state after all replies, if any, are sent. The optional action argument names that action (outcome) that the component returns.

    If you don't call transition(), the response is sent but the dialog stays in the state and subsequent user input comes back to this component. That is, invoke() is called again.

invoke: async (context) ==> {
   ...
   context.reply(payload);
   context.keepTurn(true);
   context.transition ("success"); 
}

Here are some common use cases where you would use keepTurn and transition to control the dialog flow:

Use Case Values Set for keepTurn and transition

A custom component that transitions to another state without first prompting the user for input.

  1. If applicable, use context.reply(<reply>) to send a reply.

  2. Set context.keepTurn(true).

  3. Set context.transition with either a supportedActions string (e.g., context.transition("success")) or with no argument (e.g., context.transition()).

For example, this custom component updates a variable with a list of values to be immediately displayed by the next state in the dialog flow.
invoke: async (context) => {
    const listVariableName = context.properties().variableName;
    ...
    // Write list of options to a context variable
    context.variable(listVariableName, list);
   // Navigate to next state without 
   // first prompting for user interaction.
   context.keepTurn(true);
   context.transition();
 }

A custom component that enables the skill to wait for input after control returns to the skill and before the skill transitions to another state.

  1. If applicable, use context.reply(<reply>) to send a reply.

  2. Set context.keepTurn(false) .

  3. Set context.transition with either a supportedActions string(context.transition("success")) or with no arguments (context.transition()).

For example:
context.keepTurn(false);
context.transition("success");
A custom component that gets user input without returning flow control back to the skill. For example:
  • A component passes the user input to query a backend search engine. If the skill can only accommodate a single result, but the query instead returns multiple hits, the component prompts the user for more input to filter the results. In this case, the custom component continues to handle the user input; it holds the conversation until the search engine returns a single hit. When it gets a single result, the component calls context.transition() to move on to another state as defined in the dialog flow definition.

  • A component processes a questionnaire and only transitions to another next state when all questions are answered.

  1. Do not call transition.

  2. Set keepTurn(false).

For example, this custom component outputs a quote and then displays Yes and No buttons to request another quote. It transitions back to the skill when the user clicks No.
  invoke: async (context) => {
    // Perform conversation tasks.
    const tracking_token = "a2VlcHR1cm4gZXhhbXBsZQ==";    
    const quotes = require("./json/Quotes.json");
    const quote = quotes[Math.floor(Math.random() * quotes.length)];
    
    // Check if postback action is issued. If postback action is issued, 
    // check if postback is from this component rendering. This ensures
    // that the component only responds to its own postback actions.     
    if (context.postback() && context.postback().token == tracking_token && context.postback().isNo) {
      context.keepTurn(true);
      context.transition();
    } else {
      // Show the quote of the day.
      context.reply("'" + quote.quote + "'");
      context.reply(" Quote by: " + quote.origin);
      // Create a single message with two buttons to 
      // request another quote or not.
      const mf = context.getMessageFactory();
      const message = mf.createTextMessage('Do you want another quote?')
        .addAction(mf.createPostbackAction('Yes', { isNo: false, token: tracking_token }))
        .addAction(mf.createPostbackAction('No', { isNo: true, token: tracking_token })); 
      context.reply(message);
      // Although reply() automatically sets keepTurn to false, 
      // it's good practice to explicitly set it so that it's
      // easier to see how you intend the component to behave.
      context.keepTurn(false);
    };
  }

If a component doesn’t transition to another state, then it needs to keep track of its own state, as shown in the above example.

For more complex state handling, such as giving the user the option to cancel if a data retrieval is taking too long, you can create and use a context variable. For example: context.variable("InternalComponentWaitTime", time). If you use a context variable, don't forget to reset it or set it to null before calling context.transition.

Note that as long as you don't transition, all values that are passed in as component properties are available.

The component invocation repeats without user input. For example:

  • A component pings a remote service for the status of an order until the status is returned as accepted or the component times out. If the accepted status is not returned after the fifth ping, then the component transitions with the failedOrder status.

  • The custom component hands the user over to a live agent. In this case, the user input and responses get dispatched to the agent. The component transitions to another state when either the user or the agent terminates their session.

  • Do not call transition.

  • Set context.keepTurn(true).

Here's a somewhat contrived example that shows how to repeat the invocation without waiting for user input, and then how to transition when done:
invoke: async (context) => {

  const quotes = require("./json/Quotes.json");
  const quote = quotes[Math.floor(Math.random() * quotes.length)];
  
  // Check if postback action is issued and postback is from this component rendering. 
  // This ensures that the component only responds to its own postback actions.     
  const um = context.getUserMessage()
  if (um instanceof PostbackMessage && um.getPostback() && um.getPostback()['system.state'] === context.getRequest().state && um.getPostback().isNo) {
    context.keepTurn(true);
    context.transition();
  } else {
    // Show the quote of the day.
    context.reply(`'${quote.quote}'`);
    context.reply(`Quote by: ${quote.origin}`);
    // Create a single message with two buttons to request another quote or not.
    let actions = [];

    const mf = context.getMessageFactory();
    const message = mf.createTextMessage('Do you want another quote?')
      .addAction(mf.createPostbackAction('Yes', { isNo: false }))
      .addAction(mf.createPostbackAction('No', { isNo: true }));
    context.reply(message);
    // Although reply() automatically sets keepTurn to false, it's good practice to explicitly set it so that it's
    // easier to see how you intend the component to behave.
    context.keepTurn(false);
  }
}

Access the Backend

You'll find that there are several Node.js libraries that have been built to make HTTP requests easy, and the list changes frequently. You should review the pros and cons of the currently available libraries and decide which one works best for you. We recommend that you use a library that supports promises so that you can leverage the async version of the invoke method, which was introduced in version 2.5.1, and use the await keyword to write your REST calls in a synchronous way.

One option is the node fetch API that's pre-installed with the Bots Node SDK. Access the Backend Using HTTP REST Calls in the Bots Node SDK documentation contains some code examples.

Use the SDK to Access Request and Response Payloads

You use CustomComponentContext instance methods to get the context for the invocation, access and change variables, and send results back to the dialog engine.

You can find several code examples for using these methods in Writing Custom Components and Conversation Messaging in the Bots Node SDK documentation

The SDK reference documentation is at https://github.com/oracle/bots-node-sdk.

Custom Components for Multi-Language Skills

When you design a custom component, you should consider whether the component will be used by a skill that supports more than one language.

If the custom component must support multi-language skills, then you need to know if the skills are configured for native language support or translation service.

When you use a translation service, you can translate the text from the skill. You have these options:

For native language skills, you have these options:

  • Pass the data back to the skill in variables and then output the text from a system component by passing the variables' values to a resource bundle key, as described in Use a System Component to Reference a Resource Bundle. With this option, the custom component must have metadata properties for the skill to pass the names of the variables to store the data in.

  • Use the resource bundle from the custom component to compose the custom component's reply, as described in Reference Resource Bundles from the Custom Component. You use the conversation.translate() method to get the resource bundle string to use for your call to context.reply(). This option is only valid for resource bundle definitions that use positional (numbered) parameters. It doesn't work for named parameters. With this option, the custom component must have a metadata property for name of the resource bundle key, and the named resource bundle key's parameters must match those used in the call to context.reply().

Here's an example of using the resource bundle from the custom component. In this example, fmTemplate would be set to something like ${rb('date.dayOfWeekMessage', 'lundi', '19 juillet 2021')}.

'use strict';

var IntlPolyfill    = require('intl');
Intl.DateTimeFormat = IntlPolyfill.DateTimeFormat;

module.exports = {
  metadata: () => ({
    name: 'Date.DayOfWeek',
    properties: {
      rbKey:   { required: true,  type: 'string'    }
    },
    supportedActions: []
  }),
  invoke: (context, done) => {
    const { rbKey } = context.properties();
    if (!rbKey || rbKey.startsWith('${')){
      context.transition();
            done(new Error('The state is missing the rbKey property or it uses an invalid expression to pass the value.'));
    }
    //detect user locale. If not set, define a default
    const locale  = context.getVariable('profile.locale') ? 
      context.getVariable('profile.locale') : 'en-AU';  
    const jsLocale     = locale.replace('_','-');
    //when profile languageTag is set, use it. If not, use profile.locale
    const languageTag = context.getVariable('profile.languageTag')?
                      context.getVariable('profile.languageTag') : jslocale;
   /* =============================================================
      Determine the current date in local format and 
      the day name for the locale
      ============================================================= */
    var now          = new Date();
    var dayTemplate  = new Intl.DateTimeFormat(languageTag,
      { weekday: 'long' });
    var dayOfWeek    = dayTemplate.format(now);
    var dateTemplate = new Intl.DateTimeFormat(languageTag, 
      { year: 'numeric', month: 'long', day: 'numeric'});
    var dateToday    = dateTemplate.format(now);

   /* =============================================================
      Use the context.translate() method to create the ${Freemarker} 
      template that's evaluated when the reply() is flushed to the 
      client.
      ============================================================= */
    const fmTemplate = context.translate(rbKey, dateToday, dayOfWeek );

    context.reply(fmTemplate)
                .transition()
                .logger().info('INFO : Generated FreeMarker => ' 
                + fmTemplate);
    done();  
  }
};

Ensure the Component Works in Digital Assistants

In a digital assistant conversation, a user can break a conversation flow by changing the subject. For example, if a user starts a flow to make a purchase, they might interrupt that flow to ask how much credit they have on a gift card. We call this a non sequitur. To enable the digital assistant to identify and handle non sequiturs, call the context.invalidInput(payload) method when a user utterance response is not understood in the context of the component.

In a digital conversation, the runtime determines if an invalid input is a non sequitur by searching for response matches in all skills. If it finds matches, it reroutes the flow. If not, it displays the message, if provided, prompts the user for input, and then executes the component again. The new input is passed to the component in the text property.

In a standalone skill conversation, the runtime displays the message, if provided, prompts the user for input, and then executes the component again. The new input is passed to the component in the text property.

This example code calls context.invalidInput(payload) whenever the input doesn’t convert to a number.

"use strict"
 
module.exports = {
 
    metadata: () => ({
        "name": "AgeChecker",
        "properties": {
            "minAge": { "type": "integer", "required": true }
        },
        "supportedActions": [
            "allow",
            "block",
            "unsupportedPayload"
        ]
    }),
 
    invoke: (context, done) => {
        // Parse a number out of the incoming message
        const text = context.text();
        var age = 0;
        if (text){
          const matches = text.match(/\d+/);
          if (matches) {
              age = matches[0];
          } else {
              context.invalidUserInput("Age input not understood. Please try again");
              done();
              return;
          }
        } else {
          context.transition('unsupportedPayload");
          done();
          return;
        }
 
        context.logger().info('AgeChecker: using age=' + age);
 
        // Set action based on age check
        let minAge = context.properties().minAge || 18;
        context.transition( age >= minAge ? 'allow' : 'block' );
 
        done();
    }
};

Here’s an example of how a digital assistant handles invalid input at runtime. For the first age response (twentyfive), there are no matches in any skills registered with the digital assistant so the conversation displays the specified context.invalidUserInput message. In the second age response (send money), the digital assistant finds a match so it asks if it should reroute to that flow.


Description of components-nonsequitur-conversation.png follows

You should call either context.invalidInput() or context.transition(). If you call both operations, ensure that the system.invalidUserInput variable is still set if any additional message is sent. Also note that user input components (such as Common Response and Resolve Entities components) reset system.invalidUserInput.

Say, for example, that we modify the AgeChecker component as shown below, and call context.transition() after context.invalidInput().

if (matches) {  age = matches[0]; } else { 
      context.invalidUserInput("Age input not understood. Please try again"); 
      context.transition("invalid"); 
      context.keepTurn(true);
      done();
      return;
}

In this case, the data flow needs to transition back to askage so that the user gets two output messages – "Age input not understood. Please try again" followed by "How old are you?". Here's how that might be handled in a YAML-mode dialog flow.

  askage:
    component: "System.Output"
    properties:
      text: "How old are you?"
    transitions:
      next: "checkage"
  checkage:
    component: "AgeChecker"
    properties:
      minAge: 18
    transitions:
      actions:
        allow: "crust"
        block: "underage"
        invalid: "askage"

Run the Component Service in a Development Environment

During the development phase, you can start a local service to expose the custom component package.

  1. From the top-level folder, open a terminal window and run these commands to start the service:
    npm install
    npm start
  2. To verify that the service is running, enter the following URL in a browser:
    localhost:3000/components

    The browser displays the component metadata.

  3. If you have direct Internet access, you can access the development environment from a skill:
    1. Install a tunnel, such as ngrok or Localtunnel.
    2. If you are behind a proxy, go to http://www.whatismyproxy.com/ to get the external IP address of your proxy, and then, in the terminal window that you will use to start the tunnel, enter these commands:
      export https_proxy=http://<external ip>:80
      export http_proxy=http://<external ip>:80
    3. Start the tunnel and configure it to expose port 3000.
    4. In Oracle Digital Assistant, go to the skill's Components Components icon tab and add an External component service with the metadata URL set to https://<tunnel-url>/components.
      You can use any value for the user name and password.
You can now add states for the service's components to the dialog flow and test them from the skill's Preview page.