[How to] Webex Chatbot (Part 2): Digging in to Adaptive Cards

A few months ago, I had written a blog post on building a simple chatbot with Webex. You can find that here.

At the time I was already thinking about some follow-up content, where I could walk through how to use AdaptiveCards to make the bot a little fancier - but I decided to wait a bit & see how the first post performed first.

Well, now it's a few months later & I suppose it's about time I write this 😄.

So in this post - We'll build on the code I used in the last post. We'll show how to add cards to both display information, as well as take user input.


What are AdaptiveCards?

AdaptiveCards are actually not a Webex/Cisco-specific thing! They were actually created by Microsoft with the intention of being a platform-agnostic way of displaying information. So in theory, a card schema written for a Webex bot could be taken & re-used on another messaging platform that implements AdaptiveCards - without any/much rework.

Hopefully this should mean a more consistent user experience for a bot that is supported across multiple platforms. The cards themselves can also enhance the experience by making your bot more dynamic & interactive. Maybe a developer or network engineer is okay with issuing specific syntax-based commands to a bit - but what about someone who is less computer savvy? Using cards makes it easy to provide guided input with textboxes, buttons, and toggles.

AdaptiveCards are built on a standard JSON structure with a pre-defined schema of card properties. Let's take a quick look at a simple example:

{
    "type": "AdaptiveCard",
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "version": "1.2",
    "body": [
        {
            "type": "TextBlock",
            "text": "Hello there! What's your name?",
            "wrap": true
        },
        {
            "type": "Input.Text",
            "placeholder": "Your name here",
            "id": "user_name"
        },
        {
            "type": "ActionSet",
            "actions": [
                {
                    "type": "Action.Submit",
                    "title": "Submit"
                }
            ]
        }
    ]
}

So in the JSON payload above, we're creating a card that asks for user input. We have a block of text that asks the user "What's your name?", then we provide a text input field, and finally a submit button. Looks pretty straightforward, yeah?

We'll get into the specifics around how to create that card JSON in a bit, but you can always use Microsoft's AdaptiveCard Designer to play around with card elements/structure.

So what would that card look like when rendered in Webex? Let's take a look:

sample-card

That card seems a lot more user-friendly than trying to explain to your users that they need to remember some sytax like /botcommand username set <firstname> <lastname>. Instead, they can just enter the info on the card & click submit.

Sending a Static, Default Card from a JSON File

So first we'll start off with the easier scenario. We'll have a pre-defined JSON card built, and use the bot to load & send this card response.

One note before we get started - I'll be building on my simple weather chatbot code that I mentioned earlier. If you're following along, you can pull down that code from here.

Okay, to start off - we'll be creating a card that allows a user to enter their desired zip code. Instead of having to remember the specific command syntax, a user will instead just issue the weather command & our bot will prompt for information via a card.

I've used the Microsoft Adaptive Card Designer to build out the basic card structure, which I've kept simple for now. Here's what that looks like:

{
    "type": "AdaptiveCard",
    "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
    "version": "1.2",
    "body": [
        {
            "type": "TextBlock",
            "text": "Get Current Weather",
            "wrap": true,
            "size": "Medium",
            "weight": "Bolder"
        },
        {
            "type": "TextBlock",
            "text": "Enter a Zip code:",
            "wrap": true
        },
        {
            "type": "Input.Number",
            "placeholder": "00000",
            "id": "zip_code",
            "min": 1,
            "max": 99950
        },
        {
            "type": "ActionSet",
            "actions": [
                {
                    "type": "Action.Submit",
                    "title": "Get Weather",
                    "data": {
                        "callback_keyword": "weather"
                    }
                }
            ]
        }
    ]
}

This card looks pretty similar to the example I used earlier. We have an initial text block that displays the title "Get Current Weather", and we've added attributes for bold text & a medium size.

Next, we have a normal text block prompting the user to enter their zip code. This is followed by a number input box for that data entry.

The input box has a "min" & "max" value for the zip code from 1 to 99950 - which is the full range of US zip codes. We also need to assign any input a unique "id" value - which we'll see later is used to pull out the user input from the response.

Last, we have our actions - which similar to earlier. This will present as a simple submit button with the text "Get Weather". The important note here is adding a data dictionary with a callback_keyword sub-element. We set the callback_keyword to the command we want this submission to be sent to. In our case, we want the input to be sent back to the weather command.

Now to implement. Back in our bot, we'll edit the weather.py file to load the card & assign as the default command response. Let's take a look:

### Script snippet

with open("./input-card.json", "r") as card:
    INPUT_CARD = json.load(card)

class WeatherByZIP(Command):
    def __init__(self):
        super().__init__(
            command_keyword="weather",
            help_message="Get current weather conditions by ZIP code.",
            card=INPUT_CARD,
        )
        
### Script snippet

In the above snippet, I've extracted just the pieces we'll be editing. For full examples, please see the final example repo here.

I've saved that JSON payload from earlier as input-card.json. Then we'll open that card file & assign that JSON payload to a variable called INPUT_CARD.

Since we'll expect this card to be the primary method of collecting a zip code - we'll make this card the default response from our bot when the command is triggered. In my original code, we initialized the WeatherByZIP class with card=None - which instead passed our input directly to the execute() function. In this case, we'll set that to card=INPUT_CARD to send our form.

With all that done, let's test it out:

bot-input

Collecting & Processing Card Submissions

Now that we have our input card, let's move on to processing the response.

In the original example, we expected the user to enter a command like weather 12345 - and it was easy enough to just strip the 5-digit zip code from the response.

Since a card could have many input boxes, we instead receive that data back in a structured JSON format.

If we check out the logging in the webex_bot console, we can see the payload that gets returned to us when the card is submitted:

2022-03-03 14:59:26  [INFO]  [webex_websocket_client.root._process_incoming_websocket_message]:62 attachment_actions from message_base_64_id: Webex Teams AttachmentAction:
{
  "id": "--removed--",
  "type": "submit",
  "messageId": "--removed--",
  "inputs": {
    "callback_keyword": "weather",
    "zip_code": "12345"
  },
  "personId": "--removed--",
  "roomId": "--removed--",
  "created": "2022-03-03T19:59:24.820Z"
}

Using this output, we can see exactly what data is passed back to our bot. Under imports, we do see the correct callback_keyword that we defined in our card JSON. But we also see the user input - which is 12345. This is listed under the key zip_code which is the id we assigned to our input field earlier.

Now we can easily dig through this response to pull out the data we need. This JSON payload is delivered to the execute function under the attachment_actions parameter.

So we'll make some minor modifications to our original code to pull that value out.

def execute(self, message, attachment_actions, activity):
    zip_code = attachment_actions.inputs['zip_code']

As a reminder, zip_code is the variable we're using inside the execute() function to store the requested zip code - which is then passed to the OpenWeather API.

Previously, we had just assigned the value of message to zip_code, which was the incoming text message from the user.

Instead, this time we'll fetch the zip code from the incoming card response, using attachment_actions.inputs & pulling out the zip_code key.

With that simple change completed, we should be able to ask the bot for the weather, input our zip code, then receive the same text-based output as before.

Here's what that looks like now:

bot-input-standard-response-1

As we would expect, upon hitting Get Weather - the bot returns the text-based weather report.

Next, we'll take a look at dynamically building a card to display weather info.

Dynamically Building Cards

Now we get to the real fun part. We'll need to dynamically build an AdaptiveCard response, depending on what values we want to present to the user.

I will be using a Python module called adaptivecardbuilder. There are a handful of modules available that can help dynamically build cards, but this was the one I found to be most intuitive for me. While I will walk through an example here, I would also recommend reviewing their docs for additional context/examples.

You could also generate a JSON payload manually, or build one using the card designer & try to edit the JSON dynamically to change card values - but I won't be doing that here.

So we'll start off by installing the adaptive card module:

pip install adaptivecardbuilder

Before we build out the full card response, let's take a quick look at how we can use this module.

In our execute() function, we'll create a simple card that shows what zip code was entered.

First, we'll need to add two new imports:

from webex_bot.models.response import Response
from adaptivecardbuilder import *

We'll import the Response module from webex_bot, which will be required to send any attachments / custom response to the client. We'll also import our adaptivecardbuilder module.

Next, we'll modify the execute() function to look like the following:

    def execute(self, message, attachment_actions, activity):
        zip_code = attachment_actions.inputs['zip_code']
        
        card = AdaptiveCard()
        card.add(TextBlock(text=f"Weather for {zip_code}", size="Medium", weight="Bolder"))
        card_data = json.loads(asyncio.run(card.to_json()))

        card_payload = {
            "contentType": "application/vnd.microsoft.card.adaptive",
            "content": card_data,
        }

        response = Response()
        response.text = "Test Card"
        response.attachments = card_payload

        return response

First we create a new AdaptiveCard() instance. With this new object we can add different card elements.

In this case, we add a single card element - a TextBlock. This has multiple attributes, including the actual text content, and we're specifying that we want a medium font size & bold text.

Next we serialize the card payload to JSON using asyncio.run.

In order to prepare the card object to be attached to our response, we will need to set the appropriate headers. In this case we need to wrap the JSON structure with the contentType header. We could use the response.attachments object to send any number of different file types to the user, but in this case we'll set that value to application/vnd.microsoft.card.adaptive.

Then we can assemble the response to the client. We'll create a new instance of the Response() object, where we'll define some necessary attributes.

We'll set our response.text first. This will be the text message sent to the client. If the client supports adaptive cards, then this message won't be shown to the user - however, it may still appear in pop notifications. If the client does not support adaptive cards, only this message will be shown to the user.

Next we attach our complete card payload using response.attachments.

Last but not least, we can return that response object - which is then sent back to the user.

bot-response-simple-1

In this case, we can see the bot responds back to our request with a simple card that displays the zip code.

Let's move onto building out the remainder of the card. For the purposes of this example, I'll also be pulling out a few additional items from the OpenWeather API - including sunrise, sunset, pressure, and high/low temperatures.

I'll go ahead and show the full card build here:

card = AdaptiveCard()
card.add(TextBlock(text=f"Weather for {city}", size="Medium", weight="Bolder"))
card.add(ColumnSet())
card.add(Column(width="stretch"))
card.add(FactSet())
card.add(Fact(title="Temp", value=f"{temperature}"))
card.add(Fact(title="Conditions", value=f"{conditions}"))
card.add(Fact(title="Wind", value=f"{wind}"))
card.up_one_level()
card.up_one_level()
card.add(Column(width="stretch"))
card.add(FactSet())
card.add(Fact(title="High", value=f"{high_temp}"))
card.add(Fact(title="Low", value=f"{low_temp}"))
card.add(Fact(title="Humidity", value=f"{humidity}"))
card_data = json.loads(asyncio.run(card.to_json()))

If this looks a little messy, it is. There is a cleaner way to do this, which I'll show in a moment.

The first thing to know is that the card JSON structure is hierarchical. So individual card items need to be added under the correct hierarchy - for example, you create a ColumnSet first, then add a Column underneath, then the items within that Column last.

See the below visualization of this card:

Top Level
 ↳ Text Block
 ↳ ColumnSet
   ↳ Column
     ↳ Fact Set
       ↳ Fact
       ↳ Fact
       ↳ Fact
    ↳ Column
     ↳ Fact Set
       ↳ Fact
       ↳ Fact
       ↳ Fact

So on our card, we first placed the title TextBlock. Underneath that we created a ColumnSet, which will contain two Columns.

Each one of those Columns contains a FactSet, which is a key/value pair listing of information. And of course, we add each Fact pair underneath the FactSet.

You'll notice after building one Column and FactSet, we use card.up_one_level() twice - to return back to the ColumnSet level, where we then add a second Column.

This can certainly get a little confusing at first, but I'll show a more intuitive method in a moment.

Let's take a peak at what this looks like now:

bot-response-full-1

Sub-cards & Better Card Building

In this section we'll do two quick things. First we'll take a look at a simpler, more visual way of building the cards. Then we'll also expand our example with sub-cards.

The adaptivecardsbuilder module supports a second way of building cards, which I personally find to be much easier to use.

In this method, we just create a single card.add() object - and feed it a list of all the items on the card.

For me, this makes it easier because you can add indentation to each list item - which helps keep track of where you are at in the hierarchical structure.

To navigate within the structure, we use "<" to go up one level, or "^" to return to the top level.

I've re-written the earlier example with this new structure below:

card = AdaptiveCard()
card.add(
    [
        TextBlock(text=f"Weather for {city}", size="Medium", weight="Bolder"),
        ColumnSet(),
            Column(width="stretch"),
                FactSet(),
                    Fact(title="Temp", value=f"{temperature}F"),
                    Fact(title="Conditions", value=f"{conditions}"),
                    Fact(title="Wind", value=f"{wind} mph"),
                    "<",
                "<",
            Column(width="stretch"),
                FactSet(),
                Fact(title="High", value=f"{high_temp}F"),
                Fact(title="Low", value=f"{low_temp}F"),
                "^",
        ActionSet(),
            ActionShowCard(title="More Details"),
                ColumnSet(),
                    Column(width="stretch"),
                        FactSet(),
                            Fact(title="Humidity", value=f"{humidity}%"),
                            Fact(title="Pressure", value=f"{pressure}"),
                            "<",
                        "<",
                    Column(width="stretch"),
                        FactSet(),
                            Fact(title="Sunrise", value=f"{sunrise}"),
                            Fact(title="Sunset", value=f"{sunset}"),
    ]
)
card_data = json.loads(asyncio.run(card.to_json()))

Again, you're welcome to use whichever method suits you - but I find this method to be a bit cleaner & easier to read/troubleshoot.

You can also see that I've added a new ActionSet, with a single action: ActionShowCard. This adds a nice little button to our card where we can optionally display additional information.

Here's what that looks like now:

bot-response-full-pt2

And if we expand that sub-card:

bot-response-full-pt2-expanded-2

Sub-cards can be an easy way to hide excess information to keep the response clean & focus on the important information.


Well that's it for this blog post. There's a lot more you can do with cards, including adding images, video, tables, dropdown menus, etc. But I just wanted to show a few examples of building cards. They can be a simple way to make chatbots a little less boring!

Enjoy!