Meeting feedback with Microsoft Teams Meeting App

Meeting feedback with Microsoft Teams Meeting App

In my last post I showed the very basic setup of a Microsoft Teams meeting app handling the meeting lifecycle. In fact this is a simple Teams bot solution with event based triggers. In this post I demonstrate a more realistic sample: Let’s ask the participants for a simple emoji based feedback at the end of a meeting. You might know this from a modern retail store experience once leaving point of sale or approaching the exit. Triggered by the meeting lifecycle the bot will send an adaptive card with 5 emoji buttons to request feedback. Once voted, each voter will see the current result. This will be achieved with the adaptive card universal action model (UAM).

Content

Setup

For the details on the setup refer to my last post but here in short again:

  • Set up an Azure bot channel
    • In Azure portal and under Bot Services create an “Azure Bot”
    • (Let) Create a Microsoft App ID for the bot and a secret, note this down and later put it to your .env (in production of course use an enterprise-ready scenario)
    • Under “Channels” add a featured “Teams channel”
    • Under Configuration add the following messaging endpoint: https://xxxxx.ngrok.io/api/messages (Later the xxxxx will be exchanged by the real given random ngrok url received)
    • For further explanation see here
  • Setup the solution
  • Enable Teams Developer Preview in your client via | About | Developer Preview for testing this (at the time of writing)

Initial Adaptive Card – Feedback request

As seen in my previous post there is a simple function inside the ActivityHandler specific for event-based actions. Here the initial adaptive card for feedback request can be sent.

export class BotMeetingLifecycleFeedbackBot extends TeamsActivityHandler {
    /**
     * The constructor
     * @param conversationState
     */
     public constructor(conversationState: ConversationState) {
        super();
    }
....
    async onEventActivity(context) {
        if (context.activity.type == 'event' && context.activity.name == "application/vnd.microsoft.meetingEnd") {
            var meetingObject = context.activity.value;
            const card = CardFactory.adaptiveCard(AdaptiveCardSvc.getInitialCard(meetingObject.Id));
            const message = MessageFactory.attachment(card);
            await context.sendActivity(message);
        }
    };
}

The card is constructed with a dedicated service class but once this is done it’s simply sent as an activity back to the meeting.

import { Feedback } from "../models/Feedback";
import * as ACData from "adaptivecards-templating";

export default class AdaptiveCardSvc { 
    private static initialFeedback: Feedback = {
        meetingID: "",
        votedPersons: ["00000000-0000-0000-0000-000000000000"],
        votes1: 0,
        votes2: 0,
        votes3: 0,
        votes4: 0,
        votes5: 0
    };

    private static requestCard = {
        type: "AdaptiveCard",
        schema: "http://adaptivecards.io/schemas/adaptive-card.json",
        version: "1.4",
        refresh: {
            action: {
                type: "Action.Execute",
                title: "Refresh",
                verb: "alreadyVoted",
                data: {
                      feedback: "${feedback}"
                }
            },
            userIds: "${feedback.votedPersons}"
        },
        body: [
            {
                type: "TextBlock",
                text: "How did you like the meeting?",
                wrap: true
            },
            {
                type: "ActionSet",
                actions: [
                    {
                        type: "Action.Execute",
                        title: " ",
                        verb: "vote_1",
                        iconUrl: `https://${process.env.PUBLIC_HOSTNAME}/assets/1.png`,
                        data: {
                            feedback: "${feedback}"
                        }
                    },
                    {
                        type: "Action.Execute",
                        title: " ",
                        verb: "vote_2",
                        iconUrl: `https://${process.env.PUBLIC_HOSTNAME}/assets/2.png`,
                        data: {
                            feedback: "${feedback}"
                        }
                    },
                    {
                        type: "Action.Execute",
                        title: " ",
                        verb: "vote_3",
                        iconUrl: `https://${process.env.PUBLIC_HOSTNAME}/assets/3.png`,
                        data: {
                            feedback: "${feedback}"
                        }
                    },
                    {
                        type: "Action.Execute",
                        title: " ",
                        verb: "vote_4",
                        iconUrl: `https://${process.env.PUBLIC_HOSTNAME}/assets/4.png`,
                        data: {
                            feedback: "${feedback}"
                        }
                    },
                    {
                        type: "Action.Execute",
                        title: " ",
                        verb: "vote_5",
                        iconUrl: `https://${process.env.PUBLIC_HOSTNAME}/assets/5.png`,
                        data: {
                            feedback: "${feedback}"
                        }
                    }
                ]
            }
        ]
    };

    public static getInitialCard(meetingID: string) {
        let initialFeedback = this.initialFeedback;
        initialFeedback.meetingID = meetingID;
        var template = new ACData.Template(this.requestCard);
        var card = template.expand({ $root: { "feedback": initialFeedback }});
        return card;
    }

    public static getCurrentCard(feedback: Feedback) {
        var template = new ACData.Template(this.requestCard);
        var card = template.expand({ $root: { "feedback": feedback }});
        return card;
    }
}

Three pieces are here in this extract of the service. An initial feedback data object. The card template for the request and functions to return the full card. Adaptive card templating is used here and for this there is the need to install two npm packages.

npm install adaptive-expressions adaptivecards-templating --save

In the request card templating is not used extensively. There is only the need for storing the feedback data on any action. This is because if any of this actions is clicked in the bot there is the need to know about the current results or which persons already voted. And only the data of the clicked action is returned to the bot on click. The latter one is very important for the next feature used: Refreshing cards with universal action model (UAM) which is topic of the next section. But first let’s have a look on the current result:

Adaptive Card requesting feedback

Refreshed Adaptive Card – Feedback result

What’s needed is a card that checks on rendering if the user already voted or not. If so it should be displayed the overall result to the user instead of another possibility to vote again. To achieve this, first the adaptive card needs a “refresh” part. Known from above this looks like this:

refresh: {
            action: {
                type: "Action.Execute",
                title: "Refresh",
                verb: "alreadyVoted",
                data: {
                      feedback: "${feedback}"
                }
            },
            userIds: "${feedback.votedPersons}"
        },

This refresh part is another (not “really” visible) action. It’s executed if the current user is part of the “userIds” and to be identified in the backend bot a specific “verb” needs to be given.

So once a user opens the chat with the corresponding card which aadObjectID is part of the “userIds” this action is fired (as if someone pushed the not visible button) here.
Alternatively everyone can enforce it by clicking on “Refresh card”

Adaptive Card – UAM Refresh Card

Now in the bot it’s handled inside onInvokeActivity:

export class BotMeetingLifecycleFeedbackBot extends TeamsActivityHandler {
    ...
    async onInvokeActivity(context: TurnContext): Promise<InvokeResponse<any>> {
        if (context.activity.value.action.verb === "alreadyVoted") {
            const persistedFeedback: Feedback = context.activity.value.action.data.feedback;
            let card = null;
            if (persistedFeedback.votedPersons.indexOf(context.activity.from.aadObjectId!) < 0) {
                // User did not vote yet (but pressed "refresh Card maybe")
                card = AdaptiveCardSvc.getCurrentCard(persistedFeedback);
            }
            else {
                card = AdaptiveCardSvc.getDisabledCard(persistedFeedback);
            }            
            const cardRes = {
                statusCode: StatusCodes.OK,
                type: 'application/vnd.microsoft.card.adaptive',
                value: card
            };
            const res = {
                status: StatusCodes.OK,
                body: cardRes
            };
            return res;
        }
    ....
    };
}

Inside onInvokeActivity the verb is detected so it’s clear “refresh” was invoked. The userId is checked once again (cause anyone can hit “refresh card”!) and if it’s verified the user already voted, it will return another card by getDisabledCard.

This card once again is generated by Templating and from the AdaptiveCardSvc:

export default class AdaptiveCardSvc { 
    private static resultCard = {
        type: "AdaptiveCard",
        schema: "http://adaptivecards.io/schemas/adaptive-card.json",
        version: "1.4",
        refresh: {
            action: {
                type: "Action.Execute",
                title: "Refresh",
                verb: "alreadyVoted",
                data: {
                      feedback: "${feedback}"
                }
            },
            userIds: "${feedback.votedPersons}"
        },
        body: [
                { 
                    type: "ColumnSet",
                    columns: [
                    {
                        type: "Column",
                        width: "stretch",
                        items: [
                            {
                                type: "Image",
                                size: "Medium",
                                url: `https://${process.env.PUBLIC_HOSTNAME}/assets/1.png`
                            },
                            {
                                type: "TextBlock",
                                text: "${feedback.votes1}",
                                wrap: true,
                                horizontalAlignment: "Center"
                            }
                        ]
                    },
                    {
                        type: "Column",
                        width: "stretch",
                        items: [
                            {
                                type: "Image",
                                size: "Medium",
                                url: `https://${process.env.PUBLIC_HOSTNAME}/assets/2.png`
                            },
                            {
                                type: "TextBlock",
                                text: "${feedback.votes2}",
                                wrap: true,
                                horizontalAlignment: "Center"
                            }
                        ]
                    },
                    {
                        type: "Column",
                        width: "stretch",
                        items: [
                            {
                                type: "Image",
                                size: "Medium",
                                url: `https://${process.env.PUBLIC_HOSTNAME}/assets/3.png`
                            },
                            {
                                type: "TextBlock",
                                text: "${feedback.votes3}",
                                wrap: true,
                                horizontalAlignment: "Center"
                            }
                        ]
                    },
                    {
                        type: "Column",
                        width: "stretch",
                        items: [
                            {
                                type: "Image",
                                size: "Medium",
                                url: `https://${process.env.PUBLIC_HOSTNAME}/assets/4.png`
                            },
                            {
                                type: "TextBlock",
                                text: "${feedback.votes4}",
                                wrap: true,
                                horizontalAlignment: "Center"
                            }
                        ]
                    },
                    {
                        type: "Column",
                        width: "stretch",
                        items: [
                            {
                                type: "Image",
                                size: "Medium",
                                url: `https://${process.env.PUBLIC_HOSTNAME}/assets/5.png`
                            },
                            {
                                type: "TextBlock",
                                text: "${feedback.votes5}",
                                wrap: true,
                                horizontalAlignment: "Center"
                            }
                        ]
                    }
                ]
            }
        ]
    };

    public static getDisabledCard(feedback: Feedback) {
        var template = new ACData.Template(this.resultCard);
        var card = template.expand({ $root: { "feedback": feedback }});
        return card;
    }
}

It needs the same refresh action but in the body there is only a column set rendering the same icons we had in the action buttons but now as images and together with the # of votes taken from the feedback data object. That’s simply it. The refresh action is still necessary because others could vote in the meantime, too. So the card always needs to render from the latest data object (which other, later voters might have updated in the meantime).

The result card now looks like this:

Adaptive Card result feedback

The whole solution “in action” now looks like this. Once the meeting is “ended”, the bot posts the initial adaptive card for feedback request to the meeting chat:

“Meeting ended” – Bot sends adaptive card

Now any participant can give feedback by clicking on the preferred emoji. Afterwards the result is shown to the voter:

Adaptive Card – Give Feedback (and refresh)

That’s it. This post shows a practical sample of a Teams Meeting app handling the meeting lifecycle with a bot. For further reference the whole sample is also available in my github repository. If you have further ideas on this capability do not hesitate to drop a comment. I am always interested in other ideas / implementations. Finally I would like to thank Bob German and Wajeed Shaikh from Microsoft for providing me the sample idea and their support figuring this out. But also the fabulous Rabia Williams and her blog article / sample on the new adaptive card universal action model was a great enabler for me.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in Office Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.

One thought on “Meeting feedback with Microsoft Teams Meeting App

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s