Office 365 Message Center to Planner: PowerShell walk-through–Part 2

This post has been republished via RSS; it originally appeared at: Planner Blog articles.

First published on MSDN on Nov 01, 2017
The code I am walking through here is that which drives the sample I blogged about in the posting https://blogs.msdn.microsoft.com/brismith/2017/10/23/microsoft-planner-a-change-management-solution-for-office-365/

In Part 1 I walked through the PowerShell that was reading the messages, filtering out the ones I was interested in by product and then adding the required metadata to get them to the right plan, bucket and assignee – and finally writing that to a storage queue in Microsoft Azure.  Be sure to go back if you didn’t see the update regarding the tenantId – which I had incorrectly hard-coded in the original post and since corrected.

In this part I’ll explain the code that is picking up the messages from the storage queue and creating the tasks.  The first chunk of code is setting things up:
$in = Get-Content $triggerInput -Raw
$messageCenterTask = $in | ConvertFrom-Json
$title = $messageCenterTask.title

# BriSmith@Microsoft.com https://blogs.msdn.microsoft.com/brismith

# Code to read O365 Message Center posts from the message queue and create Planner tasks

#Setup stuff for the Graph API Calls

$password = $env:aad_password | ConvertTo-SecureString -AsPlainText -Force

$Credential = New-Object -typename System.Management.Automation.PSCredential -argumentlist $env:aad_username, $password

Import-Module "D:\home\site\wwwroot\WriteTaskToPlan\Microsoft.IdentityModel.Clients.ActiveDirectory.dll"

$adal = "D:\home\site\wwwroot\WriteTaskToPlan\Microsoft.IdentityModel.Clients.ActiveDirectory.dll"
[System.Reflection.Assembly]::LoadFrom($adal)

$resourceAppIdURI = “ https://graph.microsoft.com”

$authority = “ https://login.windows.net/ $env:aadTenant”

$authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority
$uc = new-object Microsoft.IdentityModel.Clients.ActiveDirectory.UserCredential -ArgumentList $Credential.Username,$Credential.Password

$graphToken = $authContext.AcquireToken($resourceAppIdURI, $env:clientId,$uc)

$messageCenterPlanId= $env:messageCenterPlanId

The first few lines are pulling items from the queue – and I’m not doing anything with the title at that point – I was using that when I was testing the code.  The setup stuff for the Graph calls is very similar to that used in Part 1 for the calls to the Service Management API – just with a different Url – this time going to https://graph.microsoft.com .  My $graphToken is used for the subsequent calls.  I’m getting the planId from my Function App application settings environment variables – but as mentioned before if you wanted to perhaps have different products in different plans this could be an extension to the products.json and added to the data going to the storage queue.
#################################################
# Get tasks
#################################################

$headers = @{}

$headers.Add('Authorization','Bearer ' + $graphToken.AccessToken)
$headers.Add('Content-Type', "application/json")

$uri = " https://graph.microsoft.com/v1.0/planner/plans/" + $messageCenterPlanId + "/tasks"

$messageCenterPlanTasks = Invoke-WebRequest -Uri $uri -Method Get -Headers $headers -UseBasicParsing

$messageCenterPlanTasksContent = $messageCenterPlanTasks.Content | ConvertFrom-Json
$messageCenterPlanTasksValue = $messageCenterPlanTasksContent.value
$messageCenterPlanTasksValue = $messageCenterPlanTasksValue | Sort-Object bucketId, orderHint

#################################################

# Check if the task already exists by bucketId
#################################################
$taskExists = $FALSE
ForEach($existingTask in $messageCenterPlanTasksValue){
if(($existingTask.title -match $messageCenterTask.id) -and ($existingTask.bucketId -eq $messageCenterTask.bucketId)){
$taskExists = $TRUE
Break
}
}

Next I am getting the existing tasks from the plan – so if you did write different products to different plans this part would need a change.  I’m making a call to the Graph API and getting the tasks for my specific planId – then getting these into an object and looping through and comparing to the message that I pulled from the storage queue.  This might be worth some extra work as all I’m doing is checking if my existing title contains the id of my new message.  If you remember my task title is a concatenation of message id + message title.  It isn’t unknown that a message gets updated – and sometimes the title changes – but the id will not.  I’d miss the updates with this code.  There is a date you could also use and I did consider adding this somewhere I could reference.  It would be easiest adding it in to the title – as if you use something like the first characters of the description it would require another call to task details.  If there were thousands of messages then might even be worth holding that somewhere in an Azure table for reference – but that seemed overkill when I’m only pulling a couple of dozen messages.  YMMV.
# Adding the task
if(!$taskExists){
$setTask =@{}
If($messageCenterTask.dueDate){
$setTask.Add("dueDateTime", ([DateTime]$messageCenterTask.dueDate))
}
$setTask.Add("orderHint", " !")
$setTask.Add("title", $messageCenterTask.title)
$setTask.Add("planId", $messageCenterPlanId)

# Setting Applied Categories

$appliedCategories = @{}
if($messageCenterTask.categories -match 'Action'){
$appliedCategories.Add("category1",$TRUE)
}
else{$appliedCategories.Add("category1",$FALSE)}
if($messageCenterTask.categories -match 'Plan for Change'){
$appliedCategories.Add("category2",$TRUE)
}
else{$appliedCategories.Add("category2",$FALSE)}
if($messageCenterTask.categories -match 'Prevent or Fix Issues'){
$appliedCategories.Add("category3",$TRUE)
}
else{$appliedCategories.Add("category3",$FALSE)}
if($messageCenterTask.categories -match 'Advisory'){
$appliedCategories.Add("category4",$TRUE)
}
else{$appliedCategories.Add("category4",$FALSE)}
if($messageCenterTask.categories -match 'Awareness'){
$appliedCategories.Add("category5",$TRUE)
}
else{$appliedCategories.Add("category5",$FALSE)}
if($messageCenterTask.categories -match 'Stay Informed'){
$appliedCategories.Add("category6",$TRUE)
}
else{$appliedCategories.Add("category6",$FALSE)}

$setTask.Add("appliedCategories",$appliedCategories)

If the task doesn’t exist then I need to add it.  I’ll take this in a couple of chunks and this first part starts building my $setTask object by taking data from my $messageCenterTask and setting the appropriate properties.  First I set a dueDate if one exists, then add the orderHint to add this to the end and set the PlanId and title.

The categories was a tricky one as there are a number of different fields in the message center that carry status information – so I looked across all of them and decided which ones were worth exposing.  This is hard coded based on how you set the categories in your plan – but you can see from my code how I am turning on the individual categories based on the presence of the terms in my array of values in my $messageCenterTask.categories.  So this is the part that turns the coloured tabs on.
# Set bucket and assignee

$setTask.Add("bucketId", $messageCenterTask.bucketId)

$assignmentType = @{}
$assignmentType.Add("@odata.type","#microsoft.graph.plannerAssignment")
$assignmentType.Add("orderHint"," !")
$assignments = @{}
$assignments.Add($messageCenterTask.assignee, $assignmentType)
$setTask.Add("assignments", $assignments)

# Make new task call

$Request = @"

$($setTask | ConvertTo-Json)
"@

$headers = @{}

$headers.Add('Authorization','Bearer ' + $graphToken.AccessToken)
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)
$headers.Add('Prefer', "return=representation")

$newTask = Invoke-WebRequest -Uri " https://graph.microsoft.com/v1.0/planner/tasks" -Method Post -Body $Request -Headers $headers -UseBasicParsing
$newTaskContent = $newTask.Content | ConvertFrom-Json
$newTaskId = $newTaskContent.id

Continuing my $setTask object I add in the bucketId and add my $messageCenterTask.assignee.  This is actually an array which is why I set up the assignmentType then add it to the ‘assignments’.

I have all I need for my new task now – so I build up the request by converting my $setTask to json and configure my header then make the POST call to the Graph API.  Running this in an Azure Function requires the –UseBasicParsing parameter as the environment is somewhat limited and does not have the full IE engine.

I grab the returned json and pull the task Id out by converting the Content from json to a PowerShell object and getting the .id property.  I’ll need this to be able to add the rest of the task details.
# Add task details
# Pull any urls out of the description to add as attachments
$matches = New-Object System.Collections.ArrayList
$matches.clear()
$regex = 'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)'
# Find all matches in description and add to an array
select-string -Input $messageCenterTask.description -Pattern $regex -AllMatches | % { $_.Matches } | % {     $matches.add($_.Value)}

#Replacing some forbidden characters for odata properties

$externalLink = $messageCenterTask.reference -replace '\.', '%2E'
$externalLink = $externalLink -replace ':', '%3A'
$externalLink = $externalLink -replace '\#', '%23'
$externalLink = $externalLink -replace '\@', '%40'

$setTaskDetails = @{}

$setTaskDetails.Add("description", $messageCenterTask.description)
if(($messageCenterTask.reference) -or ($matches.Count -gt 0)){
$reference = @{}
$reference.Add("@odata.type", "#microsoft.graph.plannerExternalReference")
$reference.Add("alias", "Additional Information")
$reference.Add("type", "Other")
$reference.Add('previewPriority', ' !')
$references = @{}
ForEach($match in $matches){
$match = $match -replace '\.', '%2E'
$match = $match -replace ':', '%3A'
$match = $match -replace '\#', '%23'
$match = $match -replace '\@', '%40'
$references.Add($match, $reference)
}
if($messageCenterTask.reference){
$references.Add($externalLink, $reference)
}
$setTaskDetails.Add("references", $references)
$setTaskDetails.Add("previewType", "reference")
}
Start-Sleep 2

Adding the task details is basically adding the description, and any references.  Here there may be defined references such as the ‘additional information’ that I pulled through as a true $messageCenterTask.reference but I also used this field for another purpose.  Message Center posts can now be very rich – so can include videos and other Urls pointing to other documents, the video thumbnail etc.  As the Planner description cannot handle this in terms of displaying I chose to add any Urls found in the description text itself as additional references – for ease of linking – so you could easily navigate out to YouTube for example to view a pertinent video.  That is what the regex command is doing – by finding all occurrences of Urls and adding them to the $match array.

For both my true reference ($externalLink) and my found Urls ($matches) I need to do some replacement of certain characters.  This isn’t possible using a full ‘encode’ option – it just needs . : # and @ replacing to avoid some disallowed odata characters.

To add the references I first check if I have any – then  set up the static info for reference objects, then add the matches and add the externalLink if there is one – and set the previewType to reference (which adds the reference as the object ot show on the task tile).  I think we have a current bug with some types of references not rendering – so you may not see the image you are expecting quite yet.

The last line – Start-Sleep 2 was added when I was seeing failures adding the task details probably due to the task not yet being in a state where it could be edited when I make the call in the next chunk of code.  I’m sure there is a tidier way of handling this – but it worked and haven’t revisited it.
#Get Current Etag for task details

$uri = " https://graph.microsoft.com/v1.0/planner/tasks/" + $newTaskId + "/details"

$result = Invoke-WebRequest -Uri $uri -Method GET -Headers $headers -UseBasicParsing

$freshEtagTaskContent = $result.Content | ConvertFrom-Json

$Request = @"

$($setTaskDetails | ConvertTo-Json)
"@

$headers = @{}

$headers.Add('Authorization','Bearer ' + $graphToken.AccessToken)
$headers.Add('If-Match', $freshEtagTaskContent.'@odata.etag')
$headers.Add('Content-Type', "application/json")
$headers.Add('Content-length', + $Request.Length)

$uri = " https://graph.microsoft.com/v1.0/planner/tasks/" + $newTaskId + "/details"

$result = Invoke-WebRequest -Uri $uri -Method PATCH -Body $Request -Headers $headers -UseBasicParsing

}
#Write-Output "PowerShell script processed queue message '$title'"

To update the task I need to get the current Etag for the details entity of my new task – so the GET call to the specific new task Id /details ensures I have that ( $freshEtagTaskContent.’@odata.etag’ ) for the header for my subsequent PATCH call.  Then it is very similar to the /tasks call – I convert my $setTaskDetails object to json as my request body, create my header and make the patch call.  I was using the final Write-Output to double check what I was writing out – and you can see this in the function activity if you are debugging.

In my tests when running the initial function manually from the Azure portal it only takes about 5 or 6 seconds, but when looking at my hourly runs from the ‘Monitor’ option for the function I’m seeing 1 to 2 minutes, I guess because it needs loading up from cold.  Once this runs it pushes data into the storage queue – and the subsequent function gets triggered for each row (as I write this it finds 10 messages with my products) and each of those jobs takes just 3 or 4 seconds if the task already exists – and only a few seconds more if it needs to create the task.  Monitoring the storage queue using the Azure Storage Explorer I see the items picked up in 30 seconds or so – but seems much longer when I’m demonstrating the sample .

Leave a Reply

Your email address will not be published. Required fields are marked *

*

This site uses Akismet to reduce spam. Learn how your comment data is processed.