public/Send-MtTeamsMessage.ps1

<#
.SYNOPSIS
    Send an adaptive card in a teams channel with the summary of the Maester test results

.DESCRIPTION
    Uses Graph API to send an adaptive card in a teams channel with the summary of the Maester test results.

    This command requires the ChannelMessage.Send delegate permission in the Microsoft Graph API.

    Note: Teamwork.Migrate.All application permission is required if running in a non-interactive environment (Azure DevOps, GitHub) to send messages to a channel.

    When running interactively this can be done by running the following command:

    ```
    Connect-MtGraph -SendTeamsMessage
    ```

    When running in a non-interactive environment (Azure DevOps, GitHub) the ChannelMessage.Send permission
    must be granted to the application in the Microsoft Entra portal.

.EXAMPLE
    Send-MtTeamsMessage -MaesterResults $MaesterResults -TeamId '00000000-0000-0000-0000-000000000000' -TeamChannelId '19%3A00000000000000000000000000000000%40thread.tacv2' -Subject 'Maester Results' -TestResultsUri "https://github.com/contoso/maester/runs/123456789"

    Sends an Adaptive Card in a Teams Channel with the summary of the Maester test results to the specified channel along with the link to the detailed test results.

.EXAMPLE
    Send-MtTeamsMessage -MaesterResults $MaesterResults -TeamChannelWebhookUri 'https://some-url.logic.azure.com/workflows/invoke?api-version=2016-06-01' -Subject 'Maester Results' -TestResultsUri "https://github.com/contoso/maester/runs/123456789"

    Sends an Adaptive Card in a Teams Channel with the summary of the Maester test results to the specified channel along with the link to the detailed test results.


.LINK
    https://maester.dev/docs/commands/Send-MtTeamsMessage
#>

function Send-MtTeamsMessage {
    [CmdletBinding()]
    param(
        # The Maester test results returned from `Invoke-Pester -PassThru | ConvertTo-MtMaesterResult`
        [Parameter(Mandatory = $true, Position = 0)]
        [psobject] $MaesterResults,

        # The Teams team where the test results should be posted.
        # To get the TeamId, right-click on the channel in Teams and select 'Get link to channel'. Use the value of groupId. e.g. ?groupId=<TeamId>
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "MSGraph")]
        [string] $TeamId,

        # The channel where the message should be posted. e.g. 19%3A00000000000000000000000000000000%40thread.tacv2
        # To get the TeamChannelId, right-click on the channel in Teams and select 'Get link to channel'. Use the value found between channel and the channel name. e.g. /channel/<TeamChannelId>/my%20channel
        [Parameter(Mandatory = $true, Position = 2, ParameterSetName = "MSGraph")]
        [string] $TeamChannelId,

        # The URL of the webhook where the message should be posted. e.g. 'https://some-url.logic.azure.com/workflows/invoke?api-version=2016-06-01'
        # To get the webhook Url, right-click on the channel in Teams and select 'Workflow'. Create a workflow using the 'Post to a channel when a webhook request is received' template. Use the value after complete
        [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "Webhook")]
        [string] $TeamChannelWebhookUri,

        # The subject of the card. Defaults to 'Maester Test Results'.
        [string] $Subject,

        # Uri to the detailed test results page.
        [string] $TestResultsUri

    )

    <#
        # Developer guide for editing the Adaptive Card.
        - Authoring of the Adaptive Card is done using the Adaptive Card Designer.
        - Card Payload can be found in /powershell/assets/AdaptiveCardPayloadTemplate.json
        - Open https://adaptivecards.io/designer/, insert the payload and example data ($adaptiveCardData | ConvertTo-Json -depth 5)
        - Make changes as needed
        - Copy the payload and paste it into the /powershell/assets/AdaptiveCardPayloadTemplate.json file in the assets folder

        To do:
        - Add a switch to send the card to a user instead of a channel
    #>

    if (!$TeamChannelWebhookUri) {
      if (!(Test-MtContext -SendTeamsMessage)) { return }
    } else {
        # Check if TeamChannelWebhookUri is a valid URL
        $urlPattern = '^(https)://[^\s/$.?#].[^\s]*$'
        if (-not ($TeamChannelWebhookUri -match $urlPattern)) {
            Write-Output "Invalid Webhook URL: $TeamChannelWebhookUri"
            return
        }
    }

    if (!$Subject) { $Subject = "Maester Test Results" }

    $adaptiveCardTemplateFilePath = Join-Path -Path $PSScriptRoot -ChildPath '../assets/AdaptiveCardPayloadTemplate.json'
    $adaptiveCardTemplate = Get-Content -Path $adaptiveCardTemplateFilePath -Raw

    $CurrentVersion = $MaesterResults.CurrentVersion
    $LatestVersion = $MaesterResults.LatestVersion
    $ModuleVersion =
    if ($currentVersion -ne $latestVersion) {
        "$currentVersion → Latest version: $latestVersion"
    } else {
        "$currentVersion"
    }

    $NotRunCount = $MaesterResults.SkippedCount
    if ([string]::IsNullOrEmpty($MaesterResults.SkippedCount)) { $NotRunCount = "-" }

    $adaptiveCardData = @{
        title       = $Subject
        description = "Results for Maester Test run of $($MaesterResults.ExecutedAt)"
        run         = @{
            TenantName    = $MaesterResults.TenantName
            TenantId      = $MaesterResults.TenantId
            ModuleVersion = $ModuleVersion
            TotalCount    = $MaesterResults.TotalCount
            PassedCount   = $MaesterResults.PassedCount
            FailedCount   = $MaesterResults.FailedCount
            NotRunCount   = $NotRunCount
            TestResultURL = $TestResultsUri
        }
    }

    # if $adaptiveCardData.run.TestResultURL is not set, remove the TestResultURL property from the adaptive card json template
    if (!$TestResultsUri) {
        $adaptiveCardData.run.Remove("TestResultURL")
        # Remove button with link to full testresults from the adaptive card json template
        $adaptiveCardTemplate = $adaptiveCardTemplate | ConvertFrom-Json
        $adaptiveCardTemplate.body = $adaptiveCardTemplate.body | Where-Object type -ne 'ActionSet'
        $adaptiveCardTemplate = $adaptiveCardTemplate | ConvertTo-Json -Depth 10
    }

    # Identify and replace variables because 'Inline Data' is not supported by Microsoft Teams...
    # This regex matches placeholders like ${$root.run.TestResultURL} and ${$root.TenantName}
    $pattern = '\$\{\$root\.([a-zA-Z0-9_.]+)\}'

    $adaptiveCardBody = [regex]::Replace($adaptiveCardTemplate, $pattern, {
            param($match)
            # Extract the property path from the match, splitting by '.'
            $propertyPath = $match.Groups[1].Value.Split('.')
            # Navigate through the $adaptiveCardData based on the property path
            $currentValue = $adaptiveCardData
            foreach ($property in $propertyPath) {
                if ($currentValue -is [System.Collections.IDictionary] -and $currentValue.ContainsKey($property)) {
                    $currentValue = $currentValue[$property]
                } else {
                    # Debugging output to help identify where it fails
                    Write-Verbose "Failed to find property: $property in path: $($match.Groups[1].Value)"
                    # If the property does not exist, return the original placeholder
                    return $match.Value
                }
            }
            # Return the final value to replace the placeholder
            $currentValue
        })

        # Set donut values
        $adaptiveCardBody = $adaptiveCardBody.replace('99990',$adaptiveCardData.run.PassedCount)
        $adaptiveCardBody = $adaptiveCardBody.replace('99991',$adaptiveCardData.run.FailedCount)

    if (!$TeamChannelWebhookUri)
    {
        $attachmentGuid = New-Guid

        $params = @{
            subject     = $null
            body        = @{
                contentType = "html"
                content     = "<attachment id=""$attachmentGuid""></attachment>"
            }
            attachments = @(
                @{
                    id           = "$attachmentGuid"
                    contentType  = "application/vnd.microsoft.card.adaptive"
                    contentUrl   = $null
                    content      = $adaptiveCardBody.toString()
                    name         = $null
                    thumbnailUrl = $null
                }
            )
        }

      Write-Verbose -Message "Uri: $SendTeamsMessageUri"

      $SendTeamsMessageUri = "https://graph.microsoft.com/v1.0/teams/$($TeamId)/channels/$($TeamChannelId)/messages"

      Invoke-MgGraphRequest -Method POST -Uri $SendTeamsMessageUri -Body $params | Out-Null
    }else
    {
        $params = @{
            type     = "message"
            attachments = @(
                @{
                    contentType  = "application/vnd.microsoft.card.adaptive"
                    contentUrl   = $null
                    content      = $null
                }
            )
        }

      $params.attachments[0].content = ($adaptiveCardBody | convertFrom-Json)
      Write-Verbose -Message "Posting message to Teams channel using webhook: $TeamChannelWebhookUri"
      Invoke-RestMethod -Method post -ContentType 'Application/Json' -Body ($params | ConvertTo-Json -Depth 25) -Uri $TeamChannelWebhookUri
    }
}