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
“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);
[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); } }