Public/Watch-PagerArchiveMessage.ps1
|
function Watch-PagerArchiveMessage { <# .SYNOPSIS Outputs new messages as they arrive, until stopped with Ctrl+C. .DESCRIPTION Watches PagerArchive's live updates and writes each new message that matches the filters down the pipeline the moment it arrives, in arrival order, as the same PagerArchive.Message objects Get-PagerArchiveMessage returns. It runs until you press Ctrl+C (R63b). The filters are Get-PagerArchiveMessage's, except for time ranges and paging: a message must match every filter given, and a filter given several values matches any of them. Pages to ignored aliases are left out unless -IncludeIgnored is given. Aliases and capcodes piped in are combined into one watch. If the connection is lost, it warns once and keeps retrying, waiting 1, 2, 4, 8, 16 and then 30 seconds between tries (-Verbose shows each). When it's back, a second warning says how long it was down and how many messages follow; then it outputs the matching messages that arrived meanwhile, oldest first, and carries on. Each message comes out once. It uses the system's proxy settings, as every other command does. It renews your sign-in as it goes, quietly, so it can run for days. It stops with an error if your account is deactivated, you're no longer an admin, or your sign-in has ended (for example, revoked in Authentik). Like integrations, it's a convenience, not an alerting system: don't use it in place of an issued pager. .EXAMPLE Watch-PagerArchiveMessage -Capcode 0100001 SentAt Priority Alias Text ------ -------- ----- ---- 22/10/2026 4:12:09 pm Emergency Station 1 EX0000041 HSF1 SMOKE FROM SHED @ 3 FICTION ST, MADEUPTON #STN1 Each new message to capcode 0100001 as it arrives, until Ctrl+C. .EXAMPLE Watch-PaMessage -Priority Emergency | ForEach-Object -Process { "$($_.Alias): $($_.Text)" } Reacts to each Emergency page the moment it arrives. .EXAMPLE Get-PagerArchiveAlias -Name 'Station*' | Watch-PagerArchiveMessage Watches every alias whose name starts with Station. .EXAMPLE Watch-PagerArchiveMessage -Category 'Structure fire' -Verbose Also shows each retry while the connection is lost. .EXAMPLE Watch-PagerArchiveMessage -JobNumber EX0000041 | Export-Csv -Path job.csv -Append Adds each new message about one job to a CSV file as it arrives. .INPUTS System.Management.Automation.PSObject. Objects with a Capcode property (such as PagerArchive.Message), or with a Name property (such as PagerArchive.Alias), whose value is taken as an alias name. .OUTPUTS PagerArchive.Message. The same as Get-PagerArchiveMessage's: Id, SentAt, Capcode, Alias, Agency, Priority, Text (as a pager shows it, in full), RawText, Group, JobNumber, EventType, Category, Description, Address, Locality, GridRef, Units, Fields, Location, ParseFailed, AddressUnsplit, LocationMismatch, Heartbeat, Ignored and Receptions. Receptions holds the receive times known when the message arrived, usually the first client's; Get-PagerArchiveMessage shows any later ones. .LINK Get-PagerArchiveMessage .LINK Get-PagerArchiveAlias .LINK about_PagerArchive #> [CmdletBinding()] [OutputType('PagerArchive.Message')] param( # Text the message, or one of its parsed fields, contains # (case-insensitive). Default: any. No wildcards. [Parameter(Position = 0)] [ValidateNotNullOrEmpty()] [string] $Text, # Capcodes (1 to 7 digits). Default: any. No wildcards. [Parameter(ValueFromPipelineByPropertyName)] [ValidatePattern('^[0-9]{1,7}$')] [string[]] $Capcode, # Alias names. Accepts wildcards, matched case-insensitively; a name # without wildcards matches exactly. Default: any. [Parameter(ValueFromPipelineByPropertyName)] [Alias('Name')] [SupportsWildcards()] [ValidateNotNullOrEmpty()] [string[]] $Alias, # Agency names. Accepts wildcards, matched case-insensitively. # Default: any. [SupportsWildcards()] [ValidateNotNullOrEmpty()] [string[]] $Agency, # Paging groups. Accepts wildcards, matched case-insensitively. # Default: any. [SupportsWildcards()] [ValidateNotNullOrEmpty()] [string[]] $Group, # Priorities. Default: any. [ValidateSet('Emergency', 'Non-emergency', 'Admin', 'None')] [string[]] $Priority, # Event categories, as the network profile names them, or Other. # Default: any. No wildcards. [ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete) $null = $commandName, $parameterName try { (Invoke-PaApi -Method GET -Path '/api/v1/meta').categories | Where-Object -FilterScript { $_ -like "$wordToComplete*" } | ForEach-Object -Process { [System.Management.Automation.CompletionResult]::new("'$_'", $_, 'ParameterValue', $_) } } catch { $null = $_ } })] [ValidateNotNullOrEmpty()] [string[]] $Category, # Job numbers. Default: any. No wildcards. [ValidateNotNullOrEmpty()] [string[]] $JobNumber, # Only messages to capcodes with no alias. Off by default. [switch] $Unaliased, # Include pages to ignored aliases. Off by default. [switch] $IncludeIgnored, # Only messages with any of these flags. Default: any. [ValidateSet('ParseFailed', 'AddressUnsplit', 'LocationMismatch')] [string[]] $Flag ) begin { $capcodes = [System.Collections.Generic.List[string]]::new() $aliases = [System.Collections.Generic.List[string]]::new() $stream = $null } process { foreach ($c in $Capcode) { $capcodes.Add($c) } foreach ($a in $Alias) { $aliases.Add($a) } } end { # Every piped alias and capcode, in one watch. $filter = @{ Text = $Text Capcode = @($capcodes) Alias = @($aliases) Agency = $Agency Group = $Group Priority = $Priority Category = $Category JobNumber = $JobNumber Unaliased = $Unaliased IncludeIgnored = $IncludeIgnored Flag = $Flag } $filter = New-PaMessageFilter @filter $query = $filter.Query $query.receptions = 'true' $query.after_id = $null $last = $null # the next after_id: the newest message accounted for $connection = $null $connected = $false # a stream has been ready at least once $lost = $null # how long the connection has been lost, while it is $attempt = 0 # tries since it was lost $renewed = $false # renewed once after invalid_token, for this try while ($true) { $failure = $null try { $connection = Get-PaConnection if ($connection.ExpiresAt -lt [datetimeoffset]::UtcNow.AddSeconds(60)) { Request-PaToken -Connection $connection } $query.after_id = $last $names = @($query.Keys | Where-Object -FilterScript { $null -ne $query[$_] -and "$($query[$_])" -ne '' }) Write-Verbose -Message "GET /api/v1/stream ($($names -join ', '))" $stream = Open-PaEventStream -Connection $connection -PathAndQuery ('/api/v1/stream' + (ConvertTo-PaQueryString -Query $query)) Write-Debug -Message "GET /api/v1/stream answered $($stream.Status)" if ($stream.Status -eq 401 -and $stream.Code -eq 'invalid_token' -and -not $renewed) { # A token Authentik has since refused, or one that expired # early: renew once and try again. $connection.ExpiresAt = [datetimeoffset]::MinValue $renewed = $true continue } if ($stream.Status -ne 200) { $code = if ($stream.Code) { $stream.Code } else { "http_$($stream.Status)" } $message = if ($stream.Message) { $stream.Message } else { "PagerArchive answered $($stream.Status)." } throw (New-PaErrorRecord -ErrorId $code -Status $stream.Status -Message $message) } } catch { $failure = $_ } $renewed = $false if (-not $failure) { # Read events until the stream ends. try { while ($true) { $next = Read-PaStreamEvent -State $stream -SilenceSec $script:WatchSilenceSec Write-Debug -Message "event $($next.Type) $($next.Id)" if ($next.Type -eq 'message') { $row = $next.Data | ConvertFrom-Json if ($null -ne $last -and [long]$row.id -le $last) { Write-Debug -Message "message $($row.id) skipped: already output" continue } $last = [long]$row.id $message = ConvertTo-PaMessage -Row $row if (Test-PaMessageFilter -Message $message -Check $filter.Check) { $message } } elseif ($next.Type -eq 'catch-up') { # Before the messages that arrived meanwhile: how many follow. $count = [long]($next.Data | ConvertFrom-Json).count if ($null -ne $lost) { $follow = switch ($count) { 0 { 'no messages arrived meanwhile' } 1 { '1 message that arrived meanwhile follows' } default { '{0:N0} messages that arrived meanwhile follow' -f $count } } Write-Warning -Message "Reconnected after $(Format-PaDuration -Duration $lost.Elapsed); $follow." $lost = $null } elseif ($count -gt 1000) { # A quiet reconnect (the server's), with a lot to catch up. Write-Warning -Message ('{0:N0} messages arrived while reconnecting; they follow, oldest first.' -f $count) } } elseif ($next.Type -eq 'ready') { $ready = $next.Data | ConvertFrom-Json if ($null -eq $last -or [long]$ready.last_id -gt $last) { $last = [long]$ready.last_id } $connected = $true $attempt = 0 if ($null -ne $lost) { # A server without the catch-up event. Write-Warning -Message "Reconnected after $(Format-PaDuration -Duration $lost.Elapsed)." $lost = $null } if ($null -ne $query.after_id) { Write-Verbose -Message "Caught up: $($ready.caught_up) messages arrived while reconnecting." } } elseif ($next.Type -eq 'reconnect') { # The server ended the stream on purpose (the token's # expiry, an account change, or falling behind): # reconnect at once, quietly; the catch-up fills the gap. Write-Debug -Message "reconnect: $(($next.Data | ConvertFrom-Json).reason)" break } } } catch { $failure = $_ } finally { Close-PaEventStream -State $stream $stream = $null } if (-not $failure) { continue } } # A refusal, or no watch yet: stop. A lost connection: retry. $code = $failure.Exception.Data['PagerArchive.Code'] $retry = $connected -and ($failure.CategoryInfo.Category -in @('ConnectionError', 'LimitsExceeded') -or $code -eq 'renewal_failed') if (-not $retry) { $PSCmdlet.ThrowTerminatingError((Copy-PaErrorRecord -ErrorRecord $failure)) } if ($null -eq $lost) { $lost = [System.Diagnostics.Stopwatch]::StartNew() $reason = $failure.Exception.Message -replace '^PagerArchive can''t be reached at [^ ]+: ', '' Write-Warning -Message "Lost the connection to PagerArchive at $($connection.Uri) ($reason). Retrying until it's back; messages that arrive meanwhile will follow." } $attempt++ $delay = [int][math]::Min(30, [math]::Pow(2, $attempt - 1)) Write-Verbose -Message "Reconnecting to PagerArchive in $delay s (attempt $attempt)." Wait-PaRetry -Seconds $delay } } clean { # Whichever way it ends (Ctrl+C, an error, or a later command such as # Select-Object -First stopping it), the connection is closed. Close-PaEventStream -State $stream } } |