Azs.TeamsIntegration.psm1

#Requires -Modules Azs.Update.Admin, Azs.Management

Import-Module -Name Azs.Management -Force
Import-Module -Name Azs.Update.Admin -Force
# Import modules
$Location = $PSScriptRoot
[System.Reflection.Assembly]::LoadFrom("$Location\Dependencies\Antlr4.Runtime.dll")
[System.Reflection.Assembly]::LoadFrom("$Location\Dependencies\Newtonsoft.Json.dll")
[System.Reflection.Assembly]::LoadFrom("$Location\Dependencies\Jurassic.dll")
[System.Reflection.Assembly]::LoadFrom("$Location\Dependencies\AdaptiveCards.dll")
[System.Reflection.Assembly]::LoadFrom("$Location\Dependencies\AdaptiveExpressions.dll")
[System.Reflection.Assembly]::LoadFrom("$Location\Dependencies\AdaptiveCards.Templating.dll")

$TeamsMessageContent = @"
{
    "type": "message",
    "attachments": [
        {
            "contentType": "application/vnd.microsoft.card.adaptive",
            "contentUrl": null,
            "content": {{MessageContent}}
        }
    ]
}
"@


function Send-AzsUpdateStatus {
    <#
    .SYNOPSIS
        Send Update status to operators and audiences.
    .DESCRIPTION
        Retrieves update status and post a webrequest to operator and audience URIs, optionally as an adaptive card for teams.
    .EXAMPLE
        PS C:\> $OperatorUri = "https://outlook.office.com/webhook/<etc>"
        PS C:\> $AudienceUri = "https://outlook.office.com/webhook/<etc>"
        PS C:\> $Bridge = "https://teams.microsoft.com/l/meetup-join/<etc>"
        PS C:\> $stamp = "Prod"
        PS C:\> Send-AzsUpdateStatus -AudienceUri $AudienceUri -OperatorUri $OperatorUri -Stamp $stamp -BridgeInformation $Bridge
    .NOTES
        Operators are assumed to have access to the audience channel and thus do not need rich data on the update.
        Operators recieve update status and duration and messages about the execution of retrieving and building status updates for all.
    .PARAMETER AudienceUri
        Uri(s) to send Audience status updates
    .PARAMETER OperatorUri
        Uri(s) to send Operator status updates
    .PARAMETER Stamp
        An Azs.Management construct that is passed to PEP and ARM connections. Stamps must be onboarded in Azs.Management using Add-Stamp.
    .PARAMETER PepCredential
        An Azs.Management construct that is passed to PEP connections. Cloud Admin credential for PEP connections.
    .PARAMETER UpdateStatus
        NOT YET IMPLEMENT. FOR OFFLINE UPDATES
    .PARAMETER StampInformation
        NOT YET IMPLEMENT. FOR OFFLINE UPDATES
    .PARAMETER DisposePEP
        Close the PEP Session after every iteration.
    .PARAMETER Log
        Saves zip of diagnostic data for troubleshooting
    .PARAMETER LogPath
        Saves zip of diagnostic data for troubleshooting
    .PARAMETER RetrySeconds
        Wait in seconds for retries connecting to ARM and PEP. Default is 30
    .NOTES
        v0.1.1 - Send-AzsUpdate errors when Get-AzsUpdate returns nothing
        v0.1.1 Not using -log can lead to errors
    #>

    [CmdletBinding()]
    param (

        [Parameter(Mandatory = $false, HelpMessage = 'Will override connectionUri in StampDefinition')]
        [System.Uri[]]
        $AudienceUri,

        [Parameter(Mandatory = $false, HelpMessage = 'Send issues to a seperate Operator URI')]
        [System.Uri[]]
        $OperatorUri,

        [Parameter(Mandatory = $true, ParameterSetName = 'PEP')]
        [ArgumentCompleter( { (Get-Stamp).Name | Sort-Object })]
        [string]
        $Stamp,

        [Parameter(Mandatory = $false, ParameterSetName = 'PEP')]
        [pscredential]
        $PepCredential,

        [Parameter(Mandatory = $true, ParameterSetName = 'File', HelpMessage = 'Use offline XML status of update')]
        [xml]
        $UpdateStatus,

        [Parameter(Mandatory = $true, ParameterSetName = 'File', HelpMessage = 'Use offline Stamp Information JSON status of update')]
        [psobject]
        $StampInformation,

        [Parameter(Mandatory = $false, ParameterSetName = 'PEP', HelpMessage = 'Force PEP disconnect after every run.')]
        [switch]$disposePEP,

        [Parameter(Mandatory = $false, HelpMessage = 'Saves zip of diagnostic data for troubleshooting')]
        [switch]$log,

        [Parameter(Mandatory = $false, HelpMessage = 'Customize log path')]
        [string]$logPath = (Join-Path "$HOME" ".AzsMgmtTeams"),

        [Parameter(Mandatory = $false, HelpMessage = 'Wait in seconds for retries connecting to ARM and PEP. Default is 30')]
        [int]$retrySeconds = 30,

        [Parameter(Mandatory = $false, HelpMessage = 'Optionally include bridge information on the card')]
        $BridgeInformation
    )
    try {

        [switch]$global:log = $log
        if ($log) {
            $logPath = Initialize-LogPath -LogPath $logPath
        }

        if ($PSCmdlet.ParameterSetName -ne 'File') {
            $pepsession = Get-PepSessionWithRetries -stamp $stamp -waitInSeconds $waitInSeconds -PepCredential $PepCredential
            if (-not $pepSession) {
                throw "Cannot establish PEP session. Ensure Get-PepSession -stamp $stamp works."
            }

            Write-CustomLog "[$Stamp] Getting Stamp Information"
            $StampInformation = Invoke-Command $pepSession { Get-AzureStackStampInformation }
            if ($StampInformation) { $ShowStampInfo = $true }

            # get update status from URP and determine latest update state
            $updatesFromURP = Get-AzsUpdateWithRetries -stamp $stamp
            Write-CustomLog "[$Stamp] Get lastest Update from URP"
            $latestUpdateFromURP = $updatesFromURP | Select-Object -first 1
            Write-CustomLog "[$Stamp] Update $($latestUpdateFromURP.DisplayName) is $($latestUpdateFromURP.state)"

            if (-not $latestUpdateFromURP) {
                Write-CustomLog "[$Stamp] No Updates in progress according URP. This can be transient."
                $showHistory = $false
            }
            else {
                $Banner = "$($latestUpdateFromURP.DisplayName) - $($latestUpdateFromURP.State.ToString())"
                $latestUpdateRun = Get-AzsUpdateRun -UpdateName $latestUpdateFromURP.Name
            }

            if ($BridgeInformation) {
                $showBridge = $true
            }
            else {
                $showBridge = $false
            }
            # Update running show Update-Progress card
            if ($latestUpdateFromURP.state -in 'Installing', 'Preparing') {
                $UdpdateCardTemplate = Get-Content "$PSScriptRoot\Templates\Azs-TeamsIntegration-Update-InProgress.json" -Raw
                $updateStartTime = $latestUpdateRun.TimeStarted
                $totalDuration = Format-TimeSpan ([System.Xml.XmlConvert]::ToTimeSpan($latestUpdateRun.duration))

                # Only connect to PEP if update is "installing" and there is an Audience.
                if ($latestUpdateFromURP.state -in 'Installing' -and $AudienceUri) {
                    Write-CustomLog "[$Stamp] Getting Update Steps from PEP"
                    try {
                        [xml]$updateStatus = Invoke-Command $pepSession { Get-AzureStackUpdateStatus }
                        $UpdateSummary = Get-UpdateSummary -UpdateStatus $updateStatus
                        $updateProgress = $UpdateSummary.updateProgress
                        $summary = $UpdateSummary.summary
                    }
                    catch {
                        if ($_.Exception.Message -match 'The session state is Broken|No valid sessions were specified') {
                            $OperatorMessage = "[$Stamp] Update is {0} but PEP Session lost. This can happen during update. Adaptive card may be missing progress, and will be restored when possible. {1}" -f $latestUpdateFromURP.state, $_.Exception.Message
                        }
                        else {
                            $OperatorMessage = $_.Exception.Message
                        }
                        Send-TextMessage -uri $OperatorUri -message $OperatorMessage
                    }
                }

                $showProgress = if ($updateProgress.StepsInProgress) { $true } else { $false }

                # Do not show progress (steps) but show duration and status
                #$showProgress = if ($latestUpdateFromURP.state -eq 'Installing') { $true } else { $false }
                $showPrepProgress = if ($latestUpdateFromURP.state -eq 'Preparing') { $true } else { $false }
                $PrepProgress = $latestUpdateRun | Select-Object -expand ProgressStep | Select-Object Name, Status
                Write-CustomLog "[$Stamp] Steps in progress:"
                $PrepProgress | ForEach-Object { Write-CustomLog (("[$Stamp] {0}" -f $_.Name).PadRight(75) + $_.Status) }
                Write-CustomLog "[$Stamp] Update Duration: $totalDuration"
            }
            elseif ($latestUpdateFromURP.State -in 'Ready', 'Installed', 'PreparationFailed', 'InstallationFailed') {
                # Show update not running card but if there are previous updates, show the previous updates.
                # this is essential a "success" card, or "you ran this too soon" card
                # to add banner and duration to history

                Write-Verbose "$PSScriptRoot\Templates\Azs-TeamsIntegration-Update-NotRunning.json"
                $UdpdateCardTemplate = Get-Content "$PSScriptRoot\Templates\Azs-TeamsIntegration-Update-NotRunning.json" -Raw
                $showProgress = $false
                if (-not $updatesFromURP) {
                    $showHistory = $false
                }
                else {
                    $showHistory = $true
                    $updateHistory = $updatesFromURP | Select-Object Location, DisplayName, @{label = 'State'; Expression = { $_.State.tostring() } }, VersionNumber
                }

                if ('Installed') {
                    $showDuration = $true
                    $totalDurationFinal = Format-TimeSpan ([System.Xml.XmlConvert]::ToTimeSpan($latestUpdateRun.duration))
                }
                else {
                    $showDuration = $false
                }

                # the previous run errored, we want logs.
                # TO DO, ensure this is not noisy.
                if ($latestUpdateFromURP.State -in 'PreparationFailed', 'InstallationFailed') {
                    $collectAzsLogs = $true
                }
            }
            else {
                Write-CustomLog "[$Stamp] URP return unknown state: $($latestUpdateFromURP.state) : $($latestUpdateFromURP.DisplayName)"
            }
        }
        else {
            Write-CustomLog "[$Stamp] Skipping PEP connection"
        }

        # Create PSObject of progress data in "i hate nulls and ints" mode
        $UpdateData = New-Object -TypeName PSObject -Property @{
            Banner            = if ($Banner) { $Banner } else { "Azure Stack Update" }
            showPrepProgress  = $showPrepProgress
            ShowStampInfo     = $ShowStampInfo
            showDuration      = $showDuration
            totalDurationFinal = $totalDurationFinal
            Stamp             = New-Object -TypeName PSObject -Property @{
                AdminPortal   = $StampInformation.AdminExternalEndpoints.AdminPortal
                TenantPortal  = $StampInformation.TenantExternalEndpoints.TenantPortal
                Prefix        = $StampInformation.Prefix
                Hardware      = $StampInformation.HardwareOEM
                NumberOfNodes = "$($StampInformation.NumberOfNodes)"
                Version       = $StampInformation.StampVersion
                CloudId       = $StampInformation.CloudId
            }
            Update            = New-Object -TypeName PSObject -Property @{
                Prep           = $PrepProgress
                Summary        = if ($Summary) { $Summary } else { '' }
                UpdateProgress = if ($UpdateProgress) { $UpdateProgress } else { '' }
                UpdateHistory  = if ($updateHistory) { $UpdateHistory } else { '' }
            }
            ShowHistory       = $showHistory
            ShowProgress      = $showProgress
            ShowBridge        = $showBridge
            BridgeInformation = $BridgeInformation
        }

        # Get Update Detail Adaptive Card Template if there's an audience.
        if ($AudienceUri) {
            $TeamsMessageContent = Format-TeamsMessage -JsonTemplate $UdpdateCardTemplate -AdaptiveCardData $UpdateData
            Send-MessageContent -uri $AudienceUri -MessageContent $TeamsMessageContent
        }
        else {
            Write-CustomLog "[$Stamp] Skipping Adaptive card. No Audience."
        }
    }
    catch {
        $OperatorMessage = "We hit a problem sending data from the stamp to teams: Error: {0}" -f $_.exception.message
        Send-TextMessage -uri $OperatorUri -message $OperatorMessage
    }
    finally {
        if ($pepSession -and $disposePEP) {
            Write-CustomLog "[$Stamp] Closing Pepsession"
            Close-PepSession -Stamp $Stamp
        }
        else {
            Write-CustomLog "[$Stamp] Leaving Pepsession open."
        }

        if ($collectAzsLogs) {
            $pepsession = Get-PepSession -stamp $stamp -PepCredential $PepCredential
            Write-CustomLog "[$Stamp] Sending Azure Stack Logs."
            Invoke-Command $pepSession { Send-AzureStackDiagnosticLog -FitlerByRole SeedRingServices } -AsJob
            Send-TextMessage -uri $OperatorUri -message "[$Stamp] Sending Azure Stack Logs."
        }

        if ($log) {
            if ($updatesFromURP) { $updatesFromURP | ConvertTo-Json -depth 15 | Out-File $logPath\AllUpdates.json }
            if ($StampInformation) { $StampInformation | ConvertTo-Json -depth 15 | Out-File $logPath\StampInformation.xml }
            if ($UpdateStatus) { $UpdateStatus | Out-File $logPath\UpdateStatus.xml }
            if ($UpdateData) { ($UpdateData | ConvertTo-Json -Depth 20) | Out-File $logPath\UpdateData.json }
            if ($latestUpdateRun) { $latestUpdateRun | ConvertTo-Json -depth 15 | Out-File $logPath\UpdateRun.json }
            if ($TeamsMessageContent) { $TeamsMessageContent | Out-File $logPath\TeamsMessageContent.json }

            # send operator the log if logging on, highlight important messaging
            $TeamsMessageContent = Format-OperatorMessage -message (Get-Content (Join-Path $logPath AzsMgmtTeams.log))
            foreach ($connector in $OperatorUri) {
                Invoke-RestMethod -Uri $connector -Method Post -Body $TeamsMessageContent -ContentType 'application/json'
            }

            $zipFile = "{0}\AzMgmtTeamsLog_{1}.zip" -f ((Split-Path $logPath -Parent), (Split-Path $logPath -Leaf))
            Write-CustomLog "[$Stamp] Zipping logs. $zipFile"
            Compress-Archive -Path $logPath -DestinationPath $zipFile
            Write-CustomLog "[$Stamp] Removing $logPath"
            Remove-Item -Path $logPath -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

function Format-TimeSpan {
    <#
    .SYNOPSIS
        Convert timespan to Hours and Minutes
    #>

    param ([TimeSpan]$timeSpan)
    $h = $TimeSpan.Days * 24 + $TimeSpan.Hours
    $m = $TimeSpan.Minutes
    if ($h -gt 0) {
        "$h hours $m minutes"
    }
    else {
        "{0:d2} minutes" -f $m
    }
}

# Builds an 'AdaptiveCard' from the given template and data
Function Transform-AdaptiveCard {
    <#
        .SYNOPSIS
        Builds an 'AdaptiveCard' from the given template and data
    #>


    [OutputType([AdaptiveCards.AdaptiveCard])]
    Param (
        [Parameter(
            Mandatory = $True,
            ValueFromPipeline = $True,
            ValueFromPipelineByPropertyName = $True)]
        [ValidateNotNullOrEmpty()]
        [String] $JsonTemplate,

        [Parameter(
            Mandatory = $True,
            ValueFromPipeline = $True,
            ValueFromPipelineByPropertyName = $True)]
        [ValidateNotNullOrEmpty()]
        [String] $JsonData,

        [Parameter()]
        [Switch] $AsString
    )

    $AdaptiveTransformer = [AdaptiveCards.Templating.AdaptiveCardTemplate]::new($JsonTemplate)
    $AdaptiveEvaluationContext = [AdaptiveCards.Templating.EvaluationContext]::new($JsonData)
    $CardJson = $AdaptiveTransformer.Expand($AdaptiveEvaluationContext)
    $cardjson | Out-File -FilePath $LogPath\MidTransform.json
    If ($PSBoundParameters.ContainsKey('AsString')) {
        Write-Output -InputObject $CardJson
    }
    Else {
        $AdaptiveCard = ([AdaptiveCards.AdaptiveCard]::FromJson($CardJson)).Card
        Write-Output -InputObject $AdaptiveCard
    }
}

function Write-CustomLog {
    <#
    .SYNOPSIS
        Write log messages to file and verbose stream.
    #>

    [CmdletBinding()]
    param ($message)
    $message = "[{0}] {1}" -f (Get-Date).ToUniversalTime().ToString('s'), $message
    Write-Verbose -Message $message
    if ($global:log) {
        $message | Out-File -FilePath (Join-Path $logPath AzsMgmtTeams.log) -Append
    }
}

function Get-PepSessionWithRetries {
    <#
    .SYNOPSIS
        Attempt to get PEPSession with retries
    #>

    param ($stamp, $waitInSeconds, $PepSession)

    Clear-Variable retry, pepSession -ErrorAction SilentlyContinue
    while ($retry -lt 3 -and !$pepSession) {
        $retry++
        Write-CustomLog "[$Stamp] Opening Pepsession. Attempt $retry/3"
        try {
            $pepSession = Connect-PepSession -Stamp $Stamp -PEPCredential $PepSession
        }
        catch {
            Write-CustomLog ("[$Stamp] Pep Attempt failed. Reason {0}" -f $_.exception.message)
        }
        if ($pepSession) {
            return $pepSession
        }
        else {
            Start-Sleep -seconds $retrySeconds
        }
    }
}

function Get-AzsUpdateWithRetries {
    <#
    .SYNOPSIS
        Attempt to get update status from ARM with retries
    #>

    param ($stamp, $waitInSeconds)

    Write-CustomLog "[$Stamp] Connecting to Admin ARM"
    Connect-AzureStack -Stamp $stamp

    Clear-Variable retry, updatesFromURP -ErrorAction SilentlyContinue
    while ($retry -lt 3 -and !$updatesFromURP) {
        $retry++
        Write-CustomLog "[$Stamp] Getting Update from URP. Attempt $retry/3"
        try {
            # If there's an update we only need to return 1 update, other return an ordered list.
            $updatesFromURP = Get-AzsUpdate | Where-Object State -match 'Installing|Preparing'
            if (-not $updatesFromURP) {
                $updatesFromURP = Get-AzsUpdate | Sort-Object @{Expression = { [System.Version]$_.Version } } -Descending
            }
            if ($updatesFromURP) {
                # write a new property called VersionNumber to include OEM version combined view
                $updatesFromURP = $updatesFromURP | Select-object *,@{label='VersionNumber';expression={ if ($null -eq $_.version -and $null -ne $_.OemVersion) {$_.OemVersion}else{$_.version} } }
                $updatesFromURP | Foreach-Object { Write-CustomLog ("[$Stamp] Name {0} State {1} Version {2} InstalledDate {3}" -f `
                            $_.DisplayName, $_.State, $_.VersionNumber, $_.InstalledDate) }
                return $updatesFromURP
            }
            else {
                Start-Sleep -seconds $retrySeconds
            }
        }
        catch {
            Write-CustomLog ("[$Stamp] Failed getting Update from URP. This can be transient during updates. Error: {0}" -f $_.exception.message)
        }
    }
}

function Initialize-LogPath {
    <#
    .SYNOPSIS
        Determine timestamped log path and return value
    #>

    param ($logPath,[switch]$root)
    if (-not $root) {
        $logPath = Join-Path $logPath (Get-Date).ToString('yyyyMMddHHmmss')
    }
    New-Item $logPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null
    return $logPath
}

function Send-MessageContent {
    <#
    .SYNOPSIS
        Send message content
    #>

    param ([system.uri[]]$uri, $messageContent)
    if ($uri) {
        Write-CustomLog ("[$Stamp] Posting message content to Uri(s): {0}" -f ($uri -join ','))
    }
    else {
        Write-CustomLog "[$Stamp] No message to post right now"
    }

    foreach ($u in $uri) {
        Invoke-RestMethod -Uri $u -Method Post -Body $messageContent -ContentType 'application/json'
    }
}

function Format-TeamsMessage {
    <#
    .SYNOPSIS
        Transform data and adaptive card template and embed in teams message.
    #>

    param ($JsonTemplate, $AdaptiveCardData)
    Write-CustomLog "[$Stamp] Tranforming Adaptive Card"
    $UpdateProgressAdaptiveCard = Transform-AdaptiveCard -JsonTemplate $UdpdateCardTemplate -JsonData ($AdaptiveCardData | ConvertTo-Json -Depth 20) -AsString
    $TeamsMessageContent = $TeamsMessageContent.replace('{{MessageContent}}', $UpdateProgressAdaptiveCard)
    return $TeamsMessageContent
}

function Get-UpdateSummary {
    <#
    .SYNOPSIS
        Build update PSObject.
    #>

    param ($UpdateStatus)
    # All Steps
    $steps = $updateStatus.SelectNodes("//Step") | Select-Object FullStepIndex, Name, Status, StartTimeUtc, EndTimeUtc

    #Steps Success
    $Success = $updateStatus.SelectNodes("//Step") | Where-Object { $_.Status -eq 'Success' } | Select-Object FullStepIndex, Name, Status, StartTimeUtc, EndTimeUtc

    #Steps in Progress
    $sbTinyTimeformat = { (Format-TimeSpan (([datetime]::Now) - [datetime]$_.StartTimeUtc)).replace(' hours ', 'h:').replace(' minutes', 'm') }
    $stepsSb = {if ($_.ExecutionContext.Roles.Role.Nodes.Node.Name){
        "{0} ({1})" -f $_.Name, $_.ExecutionContext.Roles.Role.Nodes.Node.Name
    }
    else { $_.Name}
}
    $InProgress = $updateStatus.SelectNodes("//Step") | Where-Object Status -eq InProgress | Select-Object FullStepIndex, @{label='Name';Expression = $stepsSb }, Status, StartTimeUtc, EndTimeUtc, @{'label' = 'duration'; expression = $sbTinyTimeformat }

    #Steps in Error
    $InError = $updateStatus.SelectNodes("//Step") | Where-Object Status -eq Error | Select-Object FullStepIndex, Name, Status, StartTimeUtc, EndTimeUtc

    # Format Summary data
    $summary = New-Object -TypeName PSObject -Property @{
        Completed  = ($updateStatus.SelectNodes("//Step") | Where-Object Status -eq Success).count
        InProgress = ($updateStatus.SelectNodes("//Step") | Where-Object Status -eq InProgress).count
        Failed     = ($updateStatus.SelectNodes("//Step") | Where-Object Status -eq Error).count
    }

    # Format InProgress data
    # if inprogress steps might exceed message limit, trim it.
    if ($InProgress.count -gt 15) {
        $InProgress = $InProgress | Where-Object { $_.FullStepIndex.split('.').count -gt 2 } | Sort-Object [datetime]$_.StartTimeUtc | Select-Object -first 15 # teams message limit, removed top level jobs and trim to 15 oldest jobs.
    }

    $UpdateProgress = New-Object -TypeName PSObject -Property @{
        duration        = $totalDuration
        status          = $latestUpdateFromURP.State.ToString()
        StepsInProgress = $InProgress
        steps           = $steps
    }
    return (New-Object PSObject -Property @{summary = $summary; UpdateProgress = $UpdateProgress })
}

Export-ModuleMember -Function Send-AzsUpdateStatus


function Watch-AzsUpdate {
    <#
    .SYNOPSIS
        Continually monitor Updates for changes and invoke updates to operators and audiences.
    .DESCRIPTION
        Runs in loop every minute for (configurable) totalMinutes, default 7200.
        Each iteration checks if the operators or the audiences should updated as per OperatorFreqency and AudienceFrequency respectively.
        If Operators should be updated, the status of the most recent update is retrieved and changes (name, duration, status) are determined.
        If Audiences should be updated and changes are detected, both audiences and operators receive an update.
        If Audiences are not to be updated, i.e. update has changed but AudienceFrequency (a update) isn't due, the operators get a short status update.
    .EXAMPLE
        PS C:\> $OperatorUri = "https://outlook.office.com/webhook/<etc>"
        PS C:\> $AudienceUri = "https://outlook.office.com/webhook/<etc>"
        PS C:\> $Bridge = "https://teams.microsoft.com/l/meetup-join/<etc>"
        PS C:\> $stamp = "Prod"
        PS C:\> Watch-AzsUpdate -AudienceUri $AudienceUri -OperatorUri $OperatorUri -Stamp $stamp -BridgeInformation $Bridge -AudienceFrequency 30
        Updates audience every 30 minutes and operators (default) every 5 minutes for stamp "Prod"
    .NOTES
        Operators are assumed to have access to the audience channel and thus do not need rich data on the update.
        Operators recieve update status and duration and messages about the execution of retrieving and building status updates for all.
        OperatorFrequency must be less than or equal AudienceFrequency, it is assumes operators need update status quicker than audiences.
    .PARAMETER OperatorFrequency
        How often (approximately) the monitor the update for Operators. Must be 5, 10 or 15.
    .PARAMETER AudienceFrequency
        How often (approximately) the monitor the update for Audiences. Must be 30 or 60.
    .PARAMETER AudienceUri
        Uri(s) to send Audience status updates
    .PARAMETER OperatorUri
        Uri(s) to send Operator status updates
    .PARAMETER Stamp
        An Azs.Management construct that is passed to PEP and ARM connections. Stamps must be onboarded in Azs.Management using Add-Stamp.
    .PARAMETER PepCredential
        An Azs.Management construct that is passed to PEP connections. Cloud Admin credential for PEP connections.
    .PARAMETER UpdateStatus
        NOT YET IMPLEMENT. FOR OFFLINE UPDATES
    .PARAMETER StampInformation
        NOT YET IMPLEMENT. FOR OFFLINE UPDATES
    .PARAMETER DisposePEP
        Close the PEP Session after every iteration.
    .PARAMETER Log
        Saves zip of diagnostic data for troubleshooting
    .PARAMETER LogPath
        Saves zip of diagnostic data for troubleshooting
    .PARAMETER RetrySeconds
        Wait in seconds for retries connecting to ARM and PEP. Default is 30
    .PARAMETER TotalMinutes
        Total runtime in minutes. Defaults to 7200 minutes (5 days)
    .NOTES
        v0.1.1 Watch-AzsUpdate will fail when no updates are found using Get-AzsUpdate. Restart cmdlet when Get-AzsUpdate has output e.g. update ready, running, completed or failed.
        v0.1.1 Not using -log can lead to errors
    #>

    param (
        [Parameter(Mandatory = $true, ParameterSetName = 'PEP')]
        [ArgumentCompleter( { (Get-Stamp).Name | Sort-Object })]
        [string]
        $Stamp,

        [Parameter(Mandatory = $false, ParameterSetName = 'PEP')]
        [pscredential]
        $PepCredential,

        [Parameter(Mandatory = $false, HelpMessage = 'Uri(s) to send Audience status updates')]
        [System.Uri[]]
        $AudienceUri,

        [Parameter(Mandatory = $false, HelpMessage = 'Uri(s) to send Audience status updates')]
        [System.Uri[]]
        $OperatorUri,

        [Parameter(Mandatory = $false, HelpMessage = 'How often (approximately) the monitor the update for Operators. Must be 5, 10 or 15')]
        [ValidateSet(5, 10, 15)]
        $OperatorFrequency = 5,

        [Parameter(Mandatory = $false, HelpMessage = 'How often (approximately) to send the update to the audiences. Must be 30 or 60')]
        [ValidateSet(30, 60)]
        $AudienceFrequency = 30,

        [Parameter(Mandatory = $false, HelpMessage = 'Optionally include bridge information on the card')]
        $BridgeInformation,

        [Parameter(Mandatory = $true, ParameterSetName = 'File', HelpMessage = 'Use offline XML status of update')]
        [xml]
        $UpdateStatus,

        [Parameter(Mandatory = $true, ParameterSetName = 'File', HelpMessage = 'Use offline Stamp Information JSON status of update')]
        [psobject]
        $StampInformation,

        [Parameter(Mandatory = $false, ParameterSetName = 'PEP', HelpMessage = 'Force PEP disconnect after every run.')]
        [switch]$DisposePEP,

        [Parameter(Mandatory = $false, HelpMessage = 'Saves zip of diagnostic data for troubleshooting')]
        [switch]$Log,

        [Parameter(Mandatory = $false, HelpMessage = 'Customize log path')]
        [string]$LogPath = (Join-Path "$HOME" ".AzsMgmtTeams"),

        [Parameter(Mandatory = $false, HelpMessage = 'Wait in seconds for retries connecting to ARM and PEP. Default is 30')]
        [int]$RetrySeconds = 30,

        [Parameter(Mandatory = $false, HelpMessage = 'Total runtime in minutes. Defaults to 7200 minutes (5 days)')]
        $totalMinutes = 7200
    )
    #New-EventLog -Source AzsTeamsIntegration -LogName Application -ErrorAction SilentlyContinue
    if ($AudienceFrequency -lt $OperatorFrequency) {
        throw "You must set OperatorFrequency <= AudienceFrequency."
    }

    [switch]$global:log = $log
    if ($log) {
        $LogPath = Initialize-LogPath -logPath $logPath -Root
    }


    $i = 0
    $previousAudienceUpdate = 'NA'
    while ($i -le $totalMinutes) {
        try {
            # determine if operators and audiences should get an update
            $showOperator = if (($i % $OperatorFrequency) -eq 0) { $OperatorUri } else { $null }
            $showAudience = if (($i % $AudienceFrequency) -eq 0) { $AudienceUri } else { $null }

            if ($showOperator) {
                # Get Updates and latest update status
                $updates = Get-AzsUpdateWithRetries -stamp $stamp
                Write-CustomLog "[$Stamp] Get latest Update from URP"
                $latestUpdate = $updates | Select-Object -first 1
                if ($latestUpdate) {
                    # Get latest run and stringcat the in progress steps to use during status change detection, useful during preparation.
                    $latestUpdateRun = Get-AzsUpdateRun -UpdateName $latestUpdate.Name | Select-Object *, @{label = 'StepInProgress'; Expression = { ($_.ProgressStep.Name) -join ',' } }
                }
                else {
                    # if there's no updates i.e. new stamp latest version, we will update the operator only.
                    # audience will only be updated if there's an update inprogress, ready etc..
                    throw "No updates found. Restart cmdlet when updates are ready, running or completed."
                }
                # determine if the audience has the latest update
                if (-not $latestUpdateRun -and $previousAudienceUpdate) {
                    # no latest run (e.g. update 'Ready')
                    $sendAudienceUpdate = $true # audience gets one update until the update is in progress
                }
                elseif ($latestUpdateRun -and $previousAudienceUpdate) {
                    # there's a latest run (could be historical) and a previous update (could be initial 'NA')
                    Write-CustomLog ("[$Stamp] UpdateName {0} State {1} Duration {2} Steps {3}" -f $latestUpdate.Name, $latestUpdateRun.State, $latestUpdateRun.Duration, $latestUpdateRun.StepInProgress)
                    if (Compare-Object -ReferenceObject $latestUpdateRun -DifferenceObject $previousAudienceUpdate -Property Duration, Name, State, StepInProgress) {
                        $sendAudienceUpdate = $true # runs differ, audience needs update, this could be last update "update completed"
                        Write-CustomLog ("[$Stamp] LatestUpdateRun and PreviousAudienceUpdate differ => SendAudienceUpdate {0}" -f [bool]$SendAudienceUpdate)
                    }
                    else {
                        $sendAudienceUpdate = $false # runs don't differ, audience has the latest update.
                        Write-CustomLog ("[$Stamp] LatestUpdateRun and PreviousAudienceUpdate do not differ => SendAudienceUpdate {0}" -f [bool]$SendAudienceUpdate)
                    }
                }
                elseif (-not $latestUpdateRun -and -not $previousAudienceUpdate) {
                    # neither exist means Update 'Ready' but we've been through a loop (above)
                    $sendAudienceUpdate = $false # audience doesn't need an update.
                }
                elseif ($latestUpdateRun -and -not $previousAudienceUpdate) {
                    # edge case, two updates back to back
                    Write-CustomLog ("[$Stamp] UpdateName {0} State {1} Duration {2} Steps {3}" -f $latestUpdate.Name, $latestUpdateRun.State, $latestUpdateRun.Duration, $latestUpdateRun.StepInProgress)
                    $sendAudienceUpdate = $true
                }
                else {
                    $sendAudienceUpdate = $false # something unexpected has happened
                }

                Write-CustomLog ("[$Stamp] LatestUpdateRun: {0}, PreviousUpdateRun {1} => SendAudienceUpdate {2}" -f [bool]$latestUpdateRun, [bool]$previousAudienceUpdate, [bool]$sendAudienceUpdate)

                # if an Audience Update is due provide one
                # and remember the state of the update
                if ($showAudience -and $sendAudienceUpdate) {
                    $PSBoundParameters['AudienceUri'] = $showAudience
                    $PSBoundParameters['OperatorUri'] = $showOperator
                    $PSBoundParameters.Remove('OperatorFrequency') | Out-Null
                    $PSBoundParameters.Remove('AudienceFrequency') | Out-Null
                    Send-AzsUpdateStatus @PSBoundParameters

                    # remember what the audience just saw
                    $previousAudienceUpdate = $latestUpdateRun | Select-Object Duration, Name, State, StepInProgress
                }
                else {
                    # give short update to operator
                    $PrepProgress = $latestUpdateRun | Select-Object -expand ProgressStep | Select-Object Name, Status
                    $operatorMessage = @(
                        "[$Stamp] Update $($latestUpdate.DisplayName) is $($latestUpdate.state)"
                    )
                    $operatorMessage += $PrepProgress | ForEach-Object { ("[$Stamp] {0}" -f $_.Name).PadRight(75) + $_.Status }
                    $OperatorMessageContent = Format-OperatorMessage -message $operatorMessage
                    foreach ($opUri in $OperatorUri) {
                        Invoke-RestMethod -Uri $opUri -Method Post -Body $OperatorMessageContent -ContentType 'application/json'
                    }
                }
            }
            Write-CustomLog ("[$Stamp] Next cycle runs at {0}" -f [datetime]::now.addseconds(60))
            #Write-EventLog LogName Application -Source "AzsTeamsIntegration" EntryType Information EventID 20500 Message "Monitoring for Azure Stack Updates"
            # loop every 1 minute and evaluate if operators and audience need updates.
            Start-Sleep -Seconds 60
            $i++
        }
        catch {
            $operatorMessage = "[$Stamp] Exiting Watch-AzsUpdate. Reason: {0}" -f $_.exception.message
            Send-TextMessage -Uri $OperatorUri -Message $operatorMessage
            throw $operatorMessage
            break
        }
    }
}

Export-ModuleMember -Function Watch-AzsUpdate

function Send-TextMessage {
    <#
    .SYNOPSIS
        Remove illegal characters from exceptions, so they can be posted to operators
    #>

    param ($message, $uri)
    try {
        $text = $message -replace "[`r`n']+", ' '
        $TeamsMessageContent = New-Object PSObject -Property @{text = $text } | ConvertTo-Json
        Write-CustomLog $message
        foreach ($connector in $uri) {
            Invoke-RestMethod -Uri $connector -Method Post -Body $TeamsMessageContent -ContentType 'application/json'
        }
    }
    catch {
        throw ("Send-TextMessage failed with: {0}" -f $_.exception.message)
    }
}

function Format-OperatorMessage {
    <#
    .SYNOPSIS
        Add emphasis to certain messages to draw operator's attention
    #>

    param ($message)
    try {
        $strongmsgs = @('Collecting Azure Stack Logs',
            'We hit a problem sending data from the stamp to teams',
            'URP return unknown state',
            'PreparationFailed',
            'InstallationFailed',
            'Update is installing but PEP Session lost',
            'Update AzS (Update|Hotfix)',
            'Oem Package Update',
            'Update (.*) is')
        $message = $message | Foreach-Object {
            if ($PSITEM -match ($strongmsgs -join '|')) {
                "<strong>$PSITEM</strong>"
            }
            else {
                $PSITEM
            }
        }
        return (New-Object PSObject -Property @{text = '<body><BR>' + ($message -join '<BR>') } | ConvertTo-Json)
    }
    catch {
        throw ("Format-OperatorMessage failed with {0}" -f $_.exception.message)
    }
}
# SIG # Begin signature block
# MIIjkgYJKoZIhvcNAQcCoIIjgzCCI38CAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDXClRBsh2O9KzV
# Df6E8qV5MRzNVxYRD3+9DFdaT+BLJKCCDYEwggX/MIID56ADAgECAhMzAAABh3IX
# chVZQMcJAAAAAAGHMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD
# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy
# b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p
# bmcgUENBIDIwMTEwHhcNMjAwMzA0MTgzOTQ3WhcNMjEwMzAzMTgzOTQ3WjB0MQsw
# CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u
# ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy
# b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB
# AQDOt8kLc7P3T7MKIhouYHewMFmnq8Ayu7FOhZCQabVwBp2VS4WyB2Qe4TQBT8aB
# znANDEPjHKNdPT8Xz5cNali6XHefS8i/WXtF0vSsP8NEv6mBHuA2p1fw2wB/F0dH
# sJ3GfZ5c0sPJjklsiYqPw59xJ54kM91IOgiO2OUzjNAljPibjCWfH7UzQ1TPHc4d
# weils8GEIrbBRb7IWwiObL12jWT4Yh71NQgvJ9Fn6+UhD9x2uk3dLj84vwt1NuFQ
# itKJxIV0fVsRNR3abQVOLqpDugbr0SzNL6o8xzOHL5OXiGGwg6ekiXA1/2XXY7yV
# Fc39tledDtZjSjNbex1zzwSXAgMBAAGjggF+MIIBejAfBgNVHSUEGDAWBgorBgEE
# AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUhov4ZyO96axkJdMjpzu2zVXOJcsw
# UAYDVR0RBEkwR6RFMEMxKTAnBgNVBAsTIE1pY3Jvc29mdCBPcGVyYXRpb25zIFB1
# ZXJ0byBSaWNvMRYwFAYDVQQFEw0yMzAwMTIrNDU4Mzg1MB8GA1UdIwQYMBaAFEhu
# ZOVQBdOCqhc3NyK1bajKdQKVMFQGA1UdHwRNMEswSaBHoEWGQ2h0dHA6Ly93d3cu
# bWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY0NvZFNpZ1BDQTIwMTFfMjAxMS0w
# Ny0wOC5jcmwwYQYIKwYBBQUHAQEEVTBTMFEGCCsGAQUFBzAChkVodHRwOi8vd3d3
# Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY0NvZFNpZ1BDQTIwMTFfMjAx
# MS0wNy0wOC5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEAixmy
# S6E6vprWD9KFNIB9G5zyMuIjZAOuUJ1EK/Vlg6Fb3ZHXjjUwATKIcXbFuFC6Wr4K
# NrU4DY/sBVqmab5AC/je3bpUpjtxpEyqUqtPc30wEg/rO9vmKmqKoLPT37svc2NV
# BmGNl+85qO4fV/w7Cx7J0Bbqk19KcRNdjt6eKoTnTPHBHlVHQIHZpMxacbFOAkJr
# qAVkYZdz7ikNXTxV+GRb36tC4ByMNxE2DF7vFdvaiZP0CVZ5ByJ2gAhXMdK9+usx
# zVk913qKde1OAuWdv+rndqkAIm8fUlRnr4saSCg7cIbUwCCf116wUJ7EuJDg0vHe
# yhnCeHnBbyH3RZkHEi2ofmfgnFISJZDdMAeVZGVOh20Jp50XBzqokpPzeZ6zc1/g
# yILNyiVgE+RPkjnUQshd1f1PMgn3tns2Cz7bJiVUaqEO3n9qRFgy5JuLae6UweGf
# AeOo3dgLZxikKzYs3hDMaEtJq8IP71cX7QXe6lnMmXU/Hdfz2p897Zd+kU+vZvKI
# 3cwLfuVQgK2RZ2z+Kc3K3dRPz2rXycK5XCuRZmvGab/WbrZiC7wJQapgBodltMI5
# GMdFrBg9IeF7/rP4EqVQXeKtevTlZXjpuNhhjuR+2DMt/dWufjXpiW91bo3aH6Ea
# jOALXmoxgltCp1K7hrS6gmsvj94cLRf50QQ4U8Qwggd6MIIFYqADAgECAgphDpDS
# AAAAAAADMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMK
# V2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0
# IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0
# ZSBBdXRob3JpdHkgMjAxMTAeFw0xMTA3MDgyMDU5MDlaFw0yNjA3MDgyMTA5MDla
# MH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS
# ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMT
# H01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTEwggIiMA0GCSqGSIb3DQEB
# AQUAA4ICDwAwggIKAoICAQCr8PpyEBwurdhuqoIQTTS68rZYIZ9CGypr6VpQqrgG
# OBoESbp/wwwe3TdrxhLYC/A4wpkGsMg51QEUMULTiQ15ZId+lGAkbK+eSZzpaF7S
# 35tTsgosw6/ZqSuuegmv15ZZymAaBelmdugyUiYSL+erCFDPs0S3XdjELgN1q2jz
# y23zOlyhFvRGuuA4ZKxuZDV4pqBjDy3TQJP4494HDdVceaVJKecNvqATd76UPe/7
# 4ytaEB9NViiienLgEjq3SV7Y7e1DkYPZe7J7hhvZPrGMXeiJT4Qa8qEvWeSQOy2u
# M1jFtz7+MtOzAz2xsq+SOH7SnYAs9U5WkSE1JcM5bmR/U7qcD60ZI4TL9LoDho33
# X/DQUr+MlIe8wCF0JV8YKLbMJyg4JZg5SjbPfLGSrhwjp6lm7GEfauEoSZ1fiOIl
# XdMhSz5SxLVXPyQD8NF6Wy/VI+NwXQ9RRnez+ADhvKwCgl/bwBWzvRvUVUvnOaEP
# 6SNJvBi4RHxF5MHDcnrgcuck379GmcXvwhxX24ON7E1JMKerjt/sW5+v/N2wZuLB
# l4F77dbtS+dJKacTKKanfWeA5opieF+yL4TXV5xcv3coKPHtbcMojyyPQDdPweGF
# RInECUzF1KVDL3SV9274eCBYLBNdYJWaPk8zhNqwiBfenk70lrC8RqBsmNLg1oiM
# CwIDAQABo4IB7TCCAekwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFEhuZOVQ
# BdOCqhc3NyK1bajKdQKVMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1Ud
# DwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFHItOgIxkEO5FAVO
# 4eqnxzHRI4k0MFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwubWljcm9zb2Z0
# LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y
# Mi5jcmwwXgYIKwYBBQUHAQEEUjBQME4GCCsGAQUFBzAChkJodHRwOi8vd3d3Lm1p
# Y3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y
# Mi5jcnQwgZ8GA1UdIASBlzCBlDCBkQYJKwYBBAGCNy4DMIGDMD8GCCsGAQUFBwIB
# FjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2RvY3MvcHJpbWFyeWNw
# cy5odG0wQAYIKwYBBQUHAgIwNB4yIB0ATABlAGcAYQBsAF8AcABvAGwAaQBjAHkA
# XwBzAHQAYQB0AGUAbQBlAG4AdAAuIB0wDQYJKoZIhvcNAQELBQADggIBAGfyhqWY
# 4FR5Gi7T2HRnIpsLlhHhY5KZQpZ90nkMkMFlXy4sPvjDctFtg/6+P+gKyju/R6mj
# 82nbY78iNaWXXWWEkH2LRlBV2AySfNIaSxzzPEKLUtCw/WvjPgcuKZvmPRul1LUd
# d5Q54ulkyUQ9eHoj8xN9ppB0g430yyYCRirCihC7pKkFDJvtaPpoLpWgKj8qa1hJ
# Yx8JaW5amJbkg/TAj/NGK978O9C9Ne9uJa7lryft0N3zDq+ZKJeYTQ49C/IIidYf
# wzIY4vDFLc5bnrRJOQrGCsLGra7lstnbFYhRRVg4MnEnGn+x9Cf43iw6IGmYslmJ
# aG5vp7d0w0AFBqYBKig+gj8TTWYLwLNN9eGPfxxvFX1Fp3blQCplo8NdUmKGwx1j
# NpeG39rz+PIWoZon4c2ll9DuXWNB41sHnIc+BncG0QaxdR8UvmFhtfDcxhsEvt9B
# xw4o7t5lL+yX9qFcltgA1qFGvVnzl6UJS0gQmYAf0AApxbGbpT9Fdx41xtKiop96
# eiL6SJUfq/tHI4D1nvi/a7dLl+LrdXga7Oo3mXkYS//WsyNodeav+vyL6wuA6mk7
# r/ww7QRMjt/fdW1jkT3RnVZOT7+AVyKheBEyIXrvQQqxP/uozKRdwaGIm1dxVk5I
# RcBCyZt2WwqASGv9eZ/BvW1taslScxMNelDNMYIVZzCCFWMCAQEwgZUwfjELMAkG
# A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx
# HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9z
# b2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAxMQITMwAAAYdyF3IVWUDHCQAAAAABhzAN
# BglghkgBZQMEAgEFAKCBrjAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgor
# BgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQg2H2SHD2r
# cyKHhrty3qUHjcqu+l2bGZCvrPdAXItr3AowQgYKKwYBBAGCNwIBDDE0MDKgFIAS
# AE0AaQBjAHIAbwBzAG8AZgB0oRqAGGh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbTAN
# BgkqhkiG9w0BAQEFAASCAQCT+3/ID/1HLYNWpBbYJT+Ev7MLQT4xL54vxoWlv2Kx
# jNh0kIl0Yex5+cYVDnlRPYK3Ka9b3Lyh1H05Ta6cuow0fCxrOwXkdP6D3+SNvDbp
# 8gBQYisbp0SKYeUvPRrMU8qymMW1mSUhxMSt/CSVJJaryexcDL/Sbnm6+Ls5p2xk
# fJCTMxTLHLY1fYbWobSR7vsX6sI3p9xGVyEOaZiaHSfsoH3oyFMxV2TyglYjKyjn
# GHsbmPpuXDUqdxnYcGdIIDptpwKWnJXAbEAwl+3hBMPefAHkbDWUDAdBK0gp4NHX
# qPmhvJ1wM3dNcNnqfnE8VQMdX3dbHNlWLX8M/ZzYYjkNoYIS8TCCEu0GCisGAQQB
# gjcDAwExghLdMIIS2QYJKoZIhvcNAQcCoIISyjCCEsYCAQMxDzANBglghkgBZQME
# AgEFADCCAVUGCyqGSIb3DQEJEAEEoIIBRASCAUAwggE8AgEBBgorBgEEAYRZCgMB
# MDEwDQYJYIZIAWUDBAIBBQAEICq/NHQIF/aJ0VwwjnYI7UT98ZGkhimJKPKEM4W9
# xWwsAgZfiECfQ5QYEzIwMjAxMTAxMTkwODQ5LjUyMVowBIACAfSggdSkgdEwgc4x
# CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRt
# b25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKTAnBgNVBAsTIE1p
# Y3Jvc29mdCBPcGVyYXRpb25zIFB1ZXJ0byBSaWNvMSYwJAYDVQQLEx1UaGFsZXMg
# VFNTIEVTTjo2MEJDLUUzODMtMjYzNTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUt
# U3RhbXAgU2VydmljZaCCDkQwggT1MIID3aADAgECAhMzAAABJt+6SyK5goIHAAAA
# AAEmMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNo
# aW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29y
# cG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEw
# MB4XDTE5MTIxOTAxMTQ1OVoXDTIxMDMxNzAxMTQ1OVowgc4xCzAJBgNVBAYTAlVT
# MRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQK
# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKTAnBgNVBAsTIE1pY3Jvc29mdCBPcGVy
# YXRpb25zIFB1ZXJ0byBSaWNvMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjo2MEJD
# LUUzODMtMjYzNTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2Vydmlj
# ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ4wvoacTvMNlXQTtfF/
# Cx5Ol3X0fcjUNMvjLgTmO5+WHYJFbp725P3+qvFKDRQHWEI1Sz0gB24urVDIjXjB
# h5NVNJVMQJI2tltv7M4/4IbhZJb3xzQW7LolEoZYUZanBTUuyly9osCg4o5joViT
# 2GtmyxK+Fv5kC20l2opeaeptd/E7ceDAFRM87hiNCsK/KHyC+8+swnlg4gTOey6z
# QqhzgNsG6HrjLBuDtDs9izAMwS2yWT0T52QA9h3Q+B1C9ps2fMKMe+DHpG+0c61D
# 94Yh6cV2XHib4SBCnwIFZAeZE2UJ4qPANSYozI8PH+E5rCT3SVqYvHou97HsXvP2
# I3MCAwEAAaOCARswggEXMB0GA1UdDgQWBBRJq6wfF7B+mEKN0VimX8ajNA5hQTAf
# BgNVHSMEGDAWgBTVYzpcijGQ80N7fEYbxTNoWoVtVTBWBgNVHR8ETzBNMEugSaBH
# hkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9NaWNU
# aW1TdGFQQ0FfMjAxMC0wNy0wMS5jcmwwWgYIKwYBBQUHAQEETjBMMEoGCCsGAQUF
# BzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1RpbVN0
# YVBDQV8yMDEwLTA3LTAxLmNydDAMBgNVHRMBAf8EAjAAMBMGA1UdJQQMMAoGCCsG
# AQUFBwMIMA0GCSqGSIb3DQEBCwUAA4IBAQBAlvudaOlv9Cfzv56bnX41czF6tLtH
# LB46l6XUch+qNN45ZmOTFwLot3JjwSrn4oycQ9qTET1TFDYd1QND0LiXmKz9OqBX
# ai6S8XdyCQEZvfL82jIAs9pwsAQ6XvV9jNybPStRgF/sOAM/Deyfmej9Tg9FcRwX
# ank2qgzdZZNb8GoEze7f1orcTF0Q89IUXWIlmwEwQFYF1wjn87N4ZxL9Z/xA2m/R
# 1zizFylWP/mpamCnVfZZLkafFLNUNVmcvc+9gM7vceJs37d3ydabk4wR6ObR34sW
# aLppmyPlsI1Qq5Lu6bJCWoXzYuWpkoK6oEep1gML6SRC3HKVS3UscZhtMIIGcTCC
# BFmgAwIBAgIKYQmBKgAAAAAAAjANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMC
# VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV
# BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJv
# b3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5IDIwMTAwHhcNMTAwNzAxMjEzNjU1WhcN
# MjUwNzAxMjE0NjU1WjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3Rv
# bjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0
# aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDCCASIw
# DQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKkdDbx3EYo6IOz8E5f1+n9plGt0
# VBDVpQoAgoX77XxoSyxfxcPlYcJ2tz5mK1vwFVMnBDEfQRsalR3OCROOfGEwWbEw
# RA/xYIiEVEMM1024OAizQt2TrNZzMFcmgqNFDdDq9UeBzb8kYDJYYEbyWEeGMoQe
# dGFnkV+BVLHPk0ySwcSmXdFhE24oxhr5hoC732H8RsEnHSRnEnIaIYqvS2SJUGKx
# Xf13Hz3wV3WsvYpCTUBR0Q+cBj5nf/VmwAOWRH7v0Ev9buWayrGo8noqCjHw2k4G
# kbaICDXoeByw6ZnNPOcvRLqn9NxkvaQBwSAJk3jN/LzAyURdXhacAQVPIk0CAwEA
# AaOCAeYwggHiMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBTVYzpcijGQ80N7
# fEYbxTNoWoVtVTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTALBgNVHQ8EBAMC
# AYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBTV9lbLj+iiXGJo0T2UkFvX
# zpoYxDBWBgNVHR8ETzBNMEugSaBHhkVodHRwOi8vY3JsLm1pY3Jvc29mdC5jb20v
# cGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcmwwWgYI
# KwYBBQUHAQEETjBMMEoGCCsGAQUFBzAChj5odHRwOi8vd3d3Lm1pY3Jvc29mdC5j
# b20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNydDCBoAYDVR0g
# AQH/BIGVMIGSMIGPBgkrBgEEAYI3LgMwgYEwPQYIKwYBBQUHAgEWMWh0dHA6Ly93
# d3cubWljcm9zb2Z0LmNvbS9QS0kvZG9jcy9DUFMvZGVmYXVsdC5odG0wQAYIKwYB
# BQUHAgIwNB4yIB0ATABlAGcAYQBsAF8AUABvAGwAaQBjAHkAXwBTAHQAYQB0AGUA
# bQBlAG4AdAAuIB0wDQYJKoZIhvcNAQELBQADggIBAAfmiFEN4sbgmD+BcQM9naOh
# IW+z66bM9TG+zwXiqf76V20ZMLPCxWbJat/15/B4vceoniXj+bzta1RXCCtRgkQS
# +7lTjMz0YBKKdsxAQEGb3FwX/1z5Xhc1mCRWS3TvQhDIr79/xn/yN31aPxzymXlK
# kVIArzgPF/UveYFl2am1a+THzvbKegBvSzBEJCI8z+0DpZaPWSm8tv0E4XCfMkon
# /VWvL/625Y4zu2JfmttXQOnxzplmkIz/amJ/3cVKC5Em4jnsGUpxY517IW3DnKOi
# PPp/fZZqkHimbdLhnPkd/DjYlPTGpQqWhqS9nhquBEKDuLWAmyI4ILUl5WTs9/S/
# fmNZJQ96LjlXdqJxqgaKD4kWumGnEcua2A5HmoDF0M2n0O99g/DhO3EJ3110mCII
# YdqwUB5vvfHhAN/nMQekkzr3ZUd46PioSKv33nJ+YWtvd6mBy6cJrDm77MbL2IK0
# cs0d9LiFAR6A+xuJKlQ5slvayA1VmXqHczsI5pgt6o3gMy4SKfXAL1QnIffIrE7a
# KLixqduWsqdCosnPGUFN4Ib5KpqjEWYw07t0MkvfY3v1mYovG8chr1m1rtxEPJdQ
# cdeh0sVV42neV8HR3jDA/czmTfsNv11P6Z0eGTgvvM9YBS7vDaBQNdrvCScc1bN+
# NR4Iuto229Nfj950iEkSoYIC0jCCAjsCAQEwgfyhgdSkgdEwgc4xCzAJBgNVBAYT
# AlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYD
# VQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKTAnBgNVBAsTIE1pY3Jvc29mdCBP
# cGVyYXRpb25zIFB1ZXJ0byBSaWNvMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjo2
# MEJDLUUzODMtMjYzNTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2Vy
# dmljZaIjCgEBMAcGBSsOAwIaAxUACmcyOWmZxErpq06B8dy6oMZ6//yggYMwgYCk
# fjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH
# UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQD
# Ex1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQUFAAIF
# AONJJ9IwIhgPMjAyMDExMDExNjI1NTRaGA8yMDIwMTEwMjE2MjU1NFowdzA9Bgor
# BgEEAYRZCgQBMS8wLTAKAgUA40kn0gIBADAKAgEAAgIg4gIB/zAHAgEAAgISRDAK
# AgUA40p5UgIBADA2BgorBgEEAYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIB
# AAIDB6EgoQowCAIBAAIDAYagMA0GCSqGSIb3DQEBBQUAA4GBAI9WBYA30tN1Mf2O
# SmLadQ49HEODFSo+cQ3uAb0Sau19DVCDmIyNv6nwOzrCuV+5vrUl9+aZ1y8TMLPj
# d84B65p8z1XaRoUQe/ZAV0xTm6NUugv3Dwh1m2v8U00ZmgoT2/sIZFod7OL87Mx8
# NmYEMLflRcRtUMxuXBH3+vqHSeFXMYIDDTCCAwkCAQEwgZMwfDELMAkGA1UEBhMC
# VVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNV
# BAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRp
# bWUtU3RhbXAgUENBIDIwMTACEzMAAAEm37pLIrmCggcAAAAAASYwDQYJYIZIAWUD
# BAIBBQCgggFKMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAvBgkqhkiG9w0B
# CQQxIgQgYRARLReCDnqpCtsOhtHWk8hEtynIUoCctf7QY3/m0jowgfoGCyqGSIb3
# DQEJEAIvMYHqMIHnMIHkMIG9BCA2/c/vnr1ecAzvapOWZ2xGfAkzrkfpGcrvMW07
# CQl1DzCBmDCBgKR+MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9u
# MRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRp
# b24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwAhMzAAAB
# Jt+6SyK5goIHAAAAAAEmMCIEIAENKClUgYl1lMEZM0+0CI+y2uvbyaUiu0YzNY4V
# duEFMA0GCSqGSIb3DQEBCwUABIIBAJ4vqmm1Zjt7BX9nF9xzxkJHdXrEpb5wWbdu
# 9BQ6lCTJQwOo0ovBv5vAmdqHPuCfail1Kug6e2krpsjQ5l5g3MhqTTCnFYaCDc7H
# 34I9WELwhaWE3jHbdoFrpArx8+OBiau703RGg8ImUus5fWn+kEOBjS7ltv24J00Q
# J8mP05iMCLC+RxTAo6rOpuf7F4bPSuIqSnQhFPk4nD9c2s9zevbvBWVEfXtAFI7A
# /eTIN78bFc3cSsg4/9UWVEUxIRfHmyxNsr5xlvZh6buAyvfv7fOXDxe3skXPvmyj
# 4ht56dG3vr8otOdA4pGRNz7EiWtdtAMdqpddt+TcFpT2bQkDmfc=
# SIG # End signature block