Meeting feedback with Microsoft Teams Meeting App and Teams Toolkit for Visual Studio (C#)

Meeting feedback with Microsoft Teams Meeting App and Teams Toolkit for Visual Studio (C#)

In the past, I already created a Teams meeting app collecting feedback at the end of a meeting with a bot. I also, a while ago, started to transform some of my Teams app samples to Visual Studio and .Net versions on behalf of the new Teams Toolkit. So let’s try another one. This will illustrate a Teams Toolkit for Visual Studio and .Net Meeting lifecycle bot catching or mentioning several lifecycle events of a Teams meeting.

Content

Setup

At the time of writing this post there’s no clear documentation nor a learning path for setting up a bot solution with Teams Toolkit for Visual Studio 2022. Nevertheless, the set up is quite easy. A Setup for a new bot solution and that’s it. Some things like “Hello World Card” stuff can be get rid of and then for attending a Teams meeting some requirements need to be done.

In the manifest the following settings are necessary:

"validDomains": [
"${{BOT_ENDPOINT}}"
],
"webApplicationInfo": {
"id": "${{BOT_ID}}",
"resource": "https://RscBasedStoreApp"
},
"authorization": {
"permissions": {
"resourceSpecific": [
{
"name": "OnlineMeeting.ReadBasic.Chat",
"type": "Application"
}
]
}
}
}

This looks a bit different than in my “old” NodeJS based post but this is due to a change beginning with teams manifest schema 1.12. Nevertheless, it still enables the bot “get control over the chat” with Read access which enables to catch the lifecycle events. As a member of the chat the bot can then also post/update (it’s own) posts cause added to the chat as member. The webApplicationInfo is to establish permissions to the meeting’s chat “get control over the chat” to catch the lifecycle events.

Request feedback (once)

Request Card to vote - sent by the bot
Request Card to vote – sent by the bot

What is needed to request feedback from a meeting can be invoked at two times. Here is a very small piece of code which is executed when the meeting starts, a small text return to the meeting’s chat. But in the end this is not used within the sample.

protected override async Task OnTeamsMeetingStartAsync(Microsoft.Bot.Schema.Teams.MeetingStartEventDetails meeting, Microsoft.Bot.Builder.ITurnContext<Microsoft.Bot.Schema.IEventActivity> turnContext, System.Threading.CancellationToken cancellationToken)
{
  await turnContext.SendActivityAsync("Meeting started");
}

In the case of the this sample, the meeting end is important to catch. At this point of time the bot shall return an adaptive card, which shall enable the user to vote on the scale of 1-5 represented by emoji icons.

protected override async Task OnTeamsMeetingEndAsync(Microsoft.Bot.Schema.Teams.MeetingEndEventDetails meeting, Microsoft.Bot.Builder.ITurnContext<Microsoft.Bot.Schema.IEventActivity> turnContext, System.Threading.CancellationToken cancellationToken)
{
  AdaptiveCardsConroller adc = new AdaptiveCardsConroller(_hosturl);
  IMessageActivity initialCard = adc.GetInitialFeedback(meeting.Id);
  await turnContext.SendActivityAsync(initialCard);
}

Next comes the construction of the initial adaptive card. This is done by an own service because more constructed card versions are needed after feedback is (partially) given.

{
  "type": "AdaptiveCard",
  "schema": "http://adaptivecards.io/schemas/adaptive-card.json",
  "version": "1.4",
  "refresh": {
    "action": {
      "type": "Action.Execute",
      "title": "Refresh",
      "verb": "alreadyVoted",
      "data": {
        ....
      }
    },
    "userIds": "${votedPersons}"
  },
"body": [
  {
      "type": "TextBlock",
      "text": "How did you like the meeting?",
      "wrap": true
  },
  {
    "type": "ActionSet",
    "actions": [
      {
        "type": "Action.Execute",
        "title": " ",
        "verb": "vote_1",
        "iconUrl": "${_hosturl}/images/1.png",
        "data": {
          "meetingID": "${meetingID}",
          "votedPersons": "${votedPersons}",
          "votes1": "${formatNumber(votes1,0)}",
          "votes2": "${formatNumber(votes2,0)}",
          "votes3": "${formatNumber(votes3,0)}",
          "votes4": "${formatNumber(votes4,0)}",
          "votes5": "${formatNumber(votes5,0)}"
        }
      },
… 

First important thing here is to use at least version 1.4 to have a chance for using the Adaptive Cards Universal Action Model at a later point of time. It’s represented by the “refresh” part which is shortened here and explained later.

What is more important here is the option to vote. This is realized by an ActionSet where only the first one is shown for brevity and represented by Action.Execute with a verb once again. This can be caught inside the code while returning to the bot on giving feedback.

In Code on bot side this arrives in OnAdaptiveCardInvokeAsync. Here all necessary information are contained inside turnContext or directly invokeValue. Compared to the NodeJS version it’s a bit complex to convert that to a JSon object. Having that done based on the verb, it’s possible to detect if it’s another vote or a simple refresh from a user that are already voted.

If a vote is requested two things need to happen. Corresponding on the number of the vote (the scale 1-5) correct vote needs to be increased by one. Additionally, the current userID (AadObjectId) needs to be added to the userIDs of the card so the user is not able to vote again. In code this looks like this:

protected override async Task<AdaptiveCardInvokeResponse> OnAdaptiveCardInvokeAsync(ITurnContext<IInvokeActivity> turnContext, AdaptiveCardInvokeValue invokeValue, CancellationToken cancellationToken)
{
  string dataJson = invokeValue.Action.Data.ToString();
  Feedback feedback = JsonConvert.DeserializeObject<Feedback>(dataJson);            
  string verb = invokeValue.Action.Verb;
  if (verb == "alreadyVoted") { }
  else
  {
  switch (verb)
  {
    case "vote_1":
      feedback.votes1 += 1;
      break;
    case "vote_2":
      feedback.votes2 += 1;
      break;
                    ....
  }
  List<string> voters = new List<string>(feedback.votedPersons);
  voters.Add(turnContext.Activity.From.AadObjectId);
  feedback.votedPersons = voters.ToArray();              
  IMessageActivity deativatedCard = adc.GetDeactivatedFeedback(feedback);
  deativatedCard.Id = turnContext.Activity.ReplyToId;
  await turnContext.UpdateActivityAsync(deativatedCard);

Display feedback

Voting Result Card - sent by the bot
Voting Result Card – sent by the bot

What’s needed is a card that checks on rendering if the user already voted or not. If so, the card should be displayed with the overall result to the user instead of another possibility to vote again. The technology behind this is called Adaptive Cards Universal Action Model. To achieve this, first the adaptive card needs a “refresh” part. Known from above it was part on top (now in detail):

"refresh": {
  "action": {
    "type": "Action.Execute",
    "title": "Refresh",
    "verb": "alreadyVoted",
    "data": {
      "meetingID": "${meetingID}",
      "votedPersons": "${votedPersons}",
      "votes1": "${formatNumber(votes1,0)}",
      "votes2": "${formatNumber(votes2,0)}",
      "votes3": "${formatNumber(votes3,0)}",
      "votes4": "${formatNumber(votes4,0)}",
      "votes5": "${formatNumber(votes5,0)}"
    }
  },
  "userIds": "${votedPersons}"
}

Additionally, the card in the body only has a column set rendering the same icons we had in the action buttons before but now as images and together with the # of votes taken from the data object. That’s all.

The refresh part is a (not “really” visible) action in the card. It’s executed if the current user is part of the “userIds” and to be identified in the backend by the bot, a specific “verb” needs to be given. Coming back to the bot it can be identified inside invokeValue.Action.Verb:

if (verb == "alreadyVoted")
{
  if (feedback.votedPersons.Contains(turnContext.Activity.From.AadObjectId))
  {
    AdaptiveCardsController adc = new AdaptiveCardsController(_hosturl);
    IMessageActivity deativatedCard = adc.GetDeactivatedFeedback(feedback);
    deativatedCard.Id = turnContext.Activity.ReplyToId;
    await turnContext.UpdateActivityAsync(deativatedCard);
  }
  else
  {
    // User did not vote, yet
    IMessageActivity currentCard = adc.GetCurentFeedback(feedback);
    currentCard.Id = turnContext.Activity.ReplyToId;
    await turnContext.UpdateActivityAsync(currentCard);
  }                
}
else
{
    // See block above
}

The userId is checked once again (cause anyone can hit “refresh card”! See actions in the context menu of the card) and if it’s verified the user already voted, it will return another deativatedCard. This card once again is generated by Templating and from the AdaptiveCardsController.

Finally interesting is the part what happens if a user reaches the “refresh” part that did not vote, yet. How can this happen? How to detect? What to return?

Not everyone will vote at the exact same time. So it always needs to be detected if the calling user already voted. This can be found in either the data votedUsers or the userIds by checking aadObjectId. And then a “current” card needs to be returned: Still able to vote but with the current data (votes, userIds that already voted).

This also makes clear where the data is stored, it’s in the adaptive card itself. So in the end, the return is quite simple. If the user already voted a deactivated card, showing the result values shall be returned. If the user did not vote, yet, detected by the user ID not contained in the adaptive card’s data the user shall be still able to vote. But the card needs to hold the current data (not visible, but in the background for keeping always up to date during the next roundtrip to the bot).

And while checking during debug time, it becomes clearly detectable, the refresh run is executed once any user displays that part of the chat where the adaptive card is visible…

Debug temporary Dev Tunnel URL

In the wwwRoot folder there are the icons to be used by the adaptive cards. They are by the way also a good debug option if not sure if the bot is reachable by the URL. Simple usage of the Azure host or the temporary Dev tunnel (see dev.local. ) /images/1.png helps.

But how to deal with that while debugging in Visual Studio? A short explanation how a temporary dev tunnel is set: On every start of Visual Studio a new one is created. This also brings the need to “prepare teams app dependencies” via Teams Teams Toolkit afterwards. Now there is a system environment variable VS_TUNNEL_URL which can be caught in the program.cs on every start and put to the configuration instead of what will be there later in a hosted environment (a https://localhost:5130 e.g.).

if (app.Environment.IsDevelopment())
{
  app.UseDeveloperExceptionPage();
  builder.Configuration["BotEndpoint"] = Environment.GetEnvironmentVariable("VS_TUNNEL_URL");
}

Having that config value it can be used for later creating the card with the icons for instance as also mentioned for manually URL testing above.

protected string _hosturl;
public TeamsBot(IConfiguration config)
{
  _appId = config["MicrosoftAppId"];
  _appPassword = config["MicrosoftAppPassword"];
  _hosturl = config["BotEndpoint"];
}

That’s it. This post showed a practical sample of a Teams Meeting app handling the meeting lifecycle with a bot. And in action it looks like this:

For further reference the whole sample is also available in my GitHub repository.

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 M365 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 and Teams Toolkit for Visual Studio (C#)

Leave a comment