Things that are harder than they should be: creating and monitoring a group chat in Teams

There’s a great Robin Williams bit on golf that I always come back to when I think of things that seem to be deliberately made harder than they should be (since this is the internet, it is, of course, on youtube, but might be NSFW).  This resonated with me this week when I was trying to finally solve what should be a simple problem: how can I have a Teams application:

  • Create a group chat with a subject name
  • Add/remove participants from that chat
  • Send messages to the chat
  • Read all messages in the chat
  • Do all of this with application permissions

Back in the days of Lync server, an application could create a chat pretty easily, and get notified of all the messages, so we kind of took things for granted.  Teams, however, seems to go out of its way to make this as difficult as possible…

Now you may be asking why I’d want to do this in the first place, which is a valid question I suppose.  The answer is that the limitation of a bot only having a single conversation with an end user is a problem, because everything gets mashed into the same chat thread with no context.  By using named group chats, you can do cool stuff like:

  • Have an agent on multiple support chats at the same time with application monitoring
  • Build a chat relay app between Teams and another communications platform (like Azure Communication Services)
  • Have a bot in a group chat monitor messages for suggested action items as they happen, without having to be @mentioned.
  • and so on…

So let’s dive in!

1-Creating the chat

Ok, so first off, you have an application, and a GraphServiceClient, and you want to create a chat.  Great-there’s a chat.create permission, but you need user permissions for that.  Apps, apparently, can’t be trusted to create chats.  They can get delegated chat.create by an admin, you can actually set a policy to create a meeting as the user now, and that same application can be granted Mail.ReadWrite.All just fine, but creating a chat thread is apparently a bridge too far.  Fine-you can get an oauth token as the user easily enough.  Once you have it, you can use a GraphServiceClient to create the chat like this:

Chat c = new Chat
 {
     ChatType = ChatType.Group,
     Topic = "Group chat:" + Guid.NewGuid(),
     Members = new ChatMembersCollectionPage()
     {
         new AadUserConversationMember
         {
             Roles = new List<String>()
             {
                 "owner"
             },
             AdditionalData = new Dictionary<string, object>()
             {
                 {"user@odata.bind", "https://graph.microsoft.com/v1.0/users/"+ userID}
             }
         },
         new AadUserConversationMember
         {
             Roles = new List<String>()
             {
                 "owner"
             },
             AdditionalData = new Dictionary<string, object>()
             {
                 {"user@odata.bind", <pre wp-pre-tag-0=""></pre>quot;https://graph.microsoft.com/v1.0/users/" + AppId}
             }
         }
     }
 };

//requires TeamsAppInstallation.ReadWriteForChat
 Chat resp = await m_graphClientUser.Chats.Request().AddAsync(c);
 m_chatID = resp.Id;
var teamsAppInstallation = new TeamsAppInstallation
 {
     AdditionalData = new Dictionary<string, object>()
 {
     {"teamsApp@odata.bind", "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/" + catalogAppID}
 }
 };
 await m_graphClientUser.Chats[resp.Id].InstalledApps.Request().AddAsync(teamsAppInstallation);

This also adds the application to the chat using the catalogAppID.  This is not, as you might expect, the AAD appID, but instead the identity of the app in the app catalog, which you can find using another graph call.

2-Add/remove participants from the chat

This part of the process actually worked almost exactly as expected.  Now that the app is part of the chat, something like this:

var conversationMember = new AadUserConversationMember
{
    VisibleHistoryStartDateTime = DateTimeOffset.Parse("0001-01-01T00:00:00Z"),
    Roles = new List<String>()
    {
        "owner"
    },
    AdditionalData = new Dictionary<string, object>()
    {
        {"user@odata.bind", "https://graph.microsoft.com/v1.0/users/" + targetID}
    }
};

await m_graphClientApp.Chats[m_chatID].Members.Request().AddAsync(conversationMember);

does what you’d think and adds a user to the chat.  You can remove chat members the same way, and that includes federated AAD identities as well.  Of course, you have to have the user GUID for that, but one problem at a time.

3-Send messages to the chat

For those keeping score at home, I’ve created a Microsoft.Graph.GraphServiceClient with user permissions, and a GraphServiceClient with app permissions, but in order to send messages to the chat, I’ve found that it’s easiest to create yet ANOTHER endpoint using Microsoft.Bot.Connector.ConnectorClient.  I mean, you’d think this would work:

var chatMessage = new ChatMessage
{
    Body = new ItemBody
    {
        Content = "message from app"
    }
};

//await m_graphClientUser.Chats[m_chatID].Messages.Request().AddAsync(chatMessage);
await m_graphClientApp.Chats[m_chatID].Messages.Request().AddAsync(chatMessage);

But no.  Fails with BOTH app and user credentials.  What does work is:

IMessageActivity msg = Activity.CreateMessageActivity();
msg.Text = "message from bot";
await m_botConnectorClient.Conversations.SendToConversationAsync(m_chatID, (Activity)msg);

The nice part about this is that you can send all sorts of fun things with the message like Adaptive Card attachments that have universal actions on them.

4-Read the chat messages

 

At this point, you would assume that your app, as a full fledged member of a group chat thread, would get notified (OnTurnAsync) of new messages in the chat.  Nope-you’ll get some events this way (Participant add/remove), but even with a Chat.Read permission, you’ll need yet another endpoint to get the chat data.  In this case, it’s a change notification subscription that you’ll need to renew every 60 minutes. Before that though, you’ll need this in your manifest:
 

“webApplicationInfo”: {
“id”: “APPID”,
“resource”: “anythingCanGoHere”,
“applicationPermissions”: [
“ChatSettings.ReadWrite.Chat”,

 
 

It’s easy enough to subscribe as an app:

 

Subscription sub = new Subscription()
{
    NotificationUrl = "https://notificationDomain/changenotification/change",
    LifecycleNotificationUrl = "https://notificationDomain/changenotification/lifecycle",
    Resource = "/chats/" + m_chatID + "/messages",
    ExpirationDateTime = DateTime.Now.AddMinutes(60),
    ClientState = "GraphTester_" + m_chatID,
    EncryptionCertificate = certString,
    EncryptionCertificateId = "rndwildcard",
    IncludeResourceData = true,
    ChangeType = "created,updated",
};

Subscription s = await m_graphClientApp.Subscriptions.Request().AddAsync(sub);

 

I just handled this with a new controller that took a POST.  Seems simple enough, right?  I’ll get some json with the event data in it?  Nope, the data is encrypted, and the SDK doesn’t handle decryption.  This is the code I pulled together from a couple of different docs pages:
[HttpPost]
[Route("change")]
public async Task<IActionResult> ChangeNotification([FromQuery] string validationToken = null)
{
    try
    {
        // handle validation
        if (!string.IsNullOrEmpty(validationToken))
        {
            _log.Info(<pre wp-pre-tag-6=""></pre>quot;Received Token: '{validationToken}'");
            return Ok(validationToken);
        }

        using (StreamReader reader = new StreamReader(Request.Body))
        {
            string body = await reader.ReadToEndAsync();
            JObject jsonBody = JObject.Parse(body);
            string dataKey = jsonBody["value"][0]["encryptedContent"]["dataKey"].ToString();
            byte[] encryptedPayload = Convert.FromBase64String(jsonBody["value"][0]["encryptedContent"]["data"].ToString());

            byte[] expectedSignature = Convert.FromBase64String(jsonBody["value"][0]["encryptedContent"]["dataSignature"].ToString());

            if (m_cert == null)
                m_cert = ConfigTools.CertHelper.GetLocalCertificate("SN");  //helper lib I have to pull a cert from the machine store

            RSACryptoServiceProvider privateKeyProvider = (RSACryptoServiceProvider)m_cert.PrivateKey;
            byte[] encryptedSymmetricKey = Convert.FromBase64String(dataKey);

            // Decrypt using OAEP padding.
            byte[] decryptedSymmetricKey = privateKeyProvider.Decrypt(encryptedSymmetricKey, fOAEP: true);
            byte[] actualSignature;

            using (HMACSHA256 hmac = new HMACSHA256(decryptedSymmetricKey))
            {
                actualSignature = hmac.ComputeHash(encryptedPayload);
            }
            if (actualSignature.SequenceEqual(expectedSignature))
            {
                AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider();
                aesProvider.Key = decryptedSymmetricKey;
                aesProvider.Padding = PaddingMode.PKCS7;
                aesProvider.Mode = CipherMode.CBC;

                // Obtain the intialization vector from the symmetric key itself.
                int vectorSize = 16;
                byte[] iv = new byte[vectorSize];
                Array.Copy(decryptedSymmetricKey, iv, vectorSize);
                aesProvider.IV = iv;

                string decryptedResourceData;
                // Decrypt the resource data content.
                using (var decryptor = aesProvider.CreateDecryptor())
                {
                    using (MemoryStream msDecrypt = new MemoryStream(encryptedPayload))
                    {
                        using (CryptoStream csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Read))
                        {
                            using (StreamReader srDecrypt = new StreamReader(csDecrypt))
                            {
                                decryptedResourceData = srDecrypt.ReadToEnd();
                                _log.Info("Got decrypted resource: " + decryptedResourceData);
                                JObject message = JObject.Parse(decryptedResourceData);

                                string fromID = string.Empty;
                                bool isBotMessage = false;
                                string fromDisplayName = string.Empty;
                                if (!String.IsNullOrEmpty(message["from"]["application"].ToString()))
                                {
                                    fromID = message["from"]["application"]["id"].ToString();
                                    fromDisplayName = message["from"]["application"]["displayName"].ToString();
                                    isBotMessage = true;
                                }
                                else
                                {
                                    fromID = message["from"]["user"]["id"].ToString();
                                    fromDisplayName = message["from"]["user"]["displayName"].ToString();
                                }

                                string msgText = message["body"]["content"].ToString();
                                string msgType = message["body"]["contentType"].ToString();
                                _log.Info(<pre wp-pre-tag-6=""></pre>quot;Chat thread message: {msgText} type: {msgType} from: {fromDisplayName} {fromID}  is bot {isBotMessage}");
                            }
                        }
                    }
                }
            }
            else
            {
                _log.Error("Malformed event detected.");
            }
        }
        return Ok();
    }
    catch (Exception ex)
    {
        return StatusCode((int)HttpStatusCode.InternalServerError);
    }
}

 

Not rocket surgery, but also still way harder than it should be to get the contents of a chat message from a conversation the bot is already part of. 

 

Finally, we have an application that can do everything I expected an app in a chat should be able to do out of the box, it just took a few extra boxes.  I’m sure that some of this will get easier in the future, and I should point out that none of this is production code (very much a prototype/demo), and a bunch of things are in the beta branch.  Still, four different endpoints to manage a group chat?  I suppose it’s better than 18…
This entry was posted in Uncategorized. Bookmark the permalink.

1 Response to Things that are harder than they should be: creating and monitoring a group chat in Teams

  1. Pingback: Weekly Update 30 August 2021 – Microsoft Ignite (don’t make this mistake), Teams group chats & more | The thoughtstuff Blog

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