joshooaj.PSPushover.psm1
enum MessagePriority { Lowest = -2 Low = -1 Normal = 0 High = 1 Emergency = 2 } enum PSPushoverInformationLevel { Detailed Quiet } class PSPushoverNotificationStatus { [string]$Receipt [bool]$Acknowledged [datetime]$AcknowledgedAt [string]$AcknowledgedBy [string]$AcknowledgedByDevice [datetime]$LastDeliveredAt [bool]$Expired [datetime]$ExpiresAt [bool]$CalledBack [datetime]$CalledBackAt } class PSPushoverUserValidation { [bool]$Valid [bool]$IsGroup [string[]]$Devices [string[]]$Licenses [string]$Error } function ConvertTo-PlainText { [CmdletBinding()] param ( # Specifies a securestring value to decrypt back to a plain text string [Parameter(Mandatory, ValueFromPipeline)] [securestring] $Value ) process { ([pscredential]::new('unused', $Value)).GetNetworkCredential().Password } } function Import-PushoverConfig { <# .SYNOPSIS Imports the configuration including default API URI's and tokens .DESCRIPTION If the module has been previously used, the configuration should be present. If the config can be imported, the function returns true. Otherwise it returns false. #> [CmdletBinding()] [OutputType([bool])] param () process { if (Test-Path -Path $script:configPath) { try { Write-Verbose "Importing configuration from '$($script:configPath)'" $script:config = Import-Clixml -Path $script:configPath return $true } catch { Write-Error "Failed to import configuration from '$script:configPath'." -Exception $_.Exception } } else { Write-Verbose "No existing module configuration found at '$($script:configPath)'" } $false } } function Save-PushoverConfig { <# .SYNOPSIS Save module configuration to disk #> [CmdletBinding()] param () process { Write-Verbose "Saving the module configuration to '$($script:configPath)'" $directory = ([io.fileinfo]$script:configPath).DirectoryName if (-not (Test-Path -Path $directory)) { $null = New-Item -Path $directory -ItemType Directory -Force } $script:config | Export-Clixml -Path $script:configPath -Force } } function Send-MessageWithAttachment { <# .SYNOPSIS Sends an HTTP POST to the Pushover API using an HttpClient .DESCRIPTION When sending an image attachment with a Pushover message, you must use multipart/form-data and there doesn't seem to be a nice way to do this using Invoke-RestMethod like we're doing in the public Send-Message function. So when an attachment is provided to Send-Message, the body hashtable is constructed, and then sent over to this function to keep the main Send-Message function a manageable size. #> [CmdletBinding()] param ( # Specifies the various parameters and values expected by the Pushover messages api. [Parameter(Mandatory)] [hashtable] $Body, # Specifies the image to attach to the message as a byte array [Parameter(Mandatory)] [byte[]] $Attachment, # Optionally specifies a file name to associate with the attachment [Parameter()] [string] $FileName = 'attachment.jpg' ) begin { $uri = $script:PushoverApiUri + '/messages.json' } process { try { $client = [system.net.http.httpclient]::new() try { $content = [system.net.http.multipartformdatacontent]::new() foreach ($key in $Body.Keys) { $textContent = [system.net.http.stringcontent]::new($Body.$key) $content.Add($textContent, $key) } $jpegContent = [system.net.http.bytearraycontent]::new($Attachment) $jpegContent.Headers.ContentType = [system.net.http.headers.mediatypeheadervalue]::new('image/jpeg') $jpegContent.Headers.ContentDisposition = [system.net.http.headers.contentdispositionheadervalue]::new('form-data') $jpegContent.Headers.ContentDisposition.Name = 'attachment' $jpegContent.Headers.ContentDisposition.FileName = $FileName $content.Add($jpegContent) Write-Verbose "Message body:`r`n$($content.ReadAsStringAsync().Result.Substring(0, 2000).Replace($Body.token, "********").Replace($Body.user, "********"))" $result = $client.PostAsync($uri, $content).Result Write-Output ($result.Content.ReadAsStringAsync().Result | ConvertFrom-Json) } finally { $content.Dispose() } } finally { $client.Dispose() } } } function Get-PushoverConfig { [CmdletBinding()] param () process { [pscustomobject]@{ PSTypeName = 'PushoverConfig' ApiUri = $script:config.PushoverApiUri AppToken = $script:config.DefaultAppToken UserToken = $script:config.DefaultUserToken ConfigPath = $script:configPath } } } function Get-PushoverSound { [CmdletBinding()] [OutputType([hashtable])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [securestring] $Token ) begin { $config = Get-PushoverConfig $uriBuilder = [uribuilder]($config.ApiUri + '/sounds.json') } process { if ($null -eq $Token) { $Token = $config.AppToken if ($null -eq $Token) { throw "Token not provided and no default application token has been set using Set-PushoverConfig." } } try { $uriBuilder.Query = "token=" + ($Token | ConvertTo-PlainText) $response = Invoke-RestMethod -Method Get -Uri $uriBuilder.Uri } catch { Write-Verbose 'Handling HTTP error in Invoke-RestMethod response' $statusCode = $_.Exception.Response.StatusCode.value__ Write-Verbose "HTTP status code $statusCode" if ($statusCode -lt 400 -or $statusCode -gt 499) { throw } try { Write-Verbose 'Parsing HTTP request error response' $stream = $_.Exception.Response.GetResponseStream() $reader = [io.streamreader]::new($stream) $response = $reader.ReadToEnd() | ConvertFrom-Json if ([string]::IsNullOrWhiteSpace($response)) { throw $_ } Write-Verbose "Response body:`r`n$response" } finally { $reader.Dispose() } } if ($response.status -eq 1) { $sounds = @{} foreach ($name in $response.sounds | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name) { $sounds.$name = $response.sounds.$name } Write-Output $sounds } else { if ($null -ne $response.error) { Write-Error $response.error } elseif ($null -ne $response.errors) { foreach ($problem in $response.errors) { Write-Error $problem } } else { $response } } } } function Get-PushoverStatus { [CmdletBinding()] [OutputType([PSPushoverNotificationStatus])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [securestring] $Token, [Parameter(Mandatory, ValueFromPipeline)] [string] $Receipt ) begin { $config = Get-PushoverConfig $uriBuilder = [uribuilder]($config.ApiUri + '/receipts') } process { if ($null -eq $Token) { $Token = $config.AppToken if ($null -eq $Token) { throw "Token not provided and no default application token has been set using Set-PushoverConfig." } } $uriBuilder.Path += "/$Receipt.json" $uriBuilder.Query = "token=" + ($Token | ConvertTo-PlainText) try { $uriBuilder.Query = "token=" + ($Token | ConvertTo-PlainText) $response = Invoke-RestMethod -Method Get -Uri $uriBuilder.Uri } catch { Write-Verbose 'Handling HTTP error in Invoke-RestMethod response' $statusCode = $_.Exception.Response.StatusCode.value__ Write-Verbose "HTTP status code $statusCode" if ($statusCode -lt 400 -or $statusCode -gt 499) { throw } try { Write-Verbose 'Parsing HTTP request error response' $stream = $_.Exception.Response.GetResponseStream() $reader = [io.streamreader]::new($stream) $response = $reader.ReadToEnd() | ConvertFrom-Json if ([string]::IsNullOrWhiteSpace($response)) { throw $_ } Write-Verbose "Response body:`r`n$response" } finally { $reader.Dispose() } } if ($response.status -eq 1) { [PSPushoverNotificationStatus]@{ Receipt = $Receipt Acknowledged = [bool]$response.acknowledged AcknowledgedAt = [datetimeoffset]::FromUnixTimeSeconds($response.acknowledged_at).DateTime.ToLocalTime() AcknowledgedBy = $response.acknowledged_by AcknowledgedByDevice = $response.acknowledged_by_device LastDeliveredAt = [datetimeoffset]::FromUnixTimeSeconds($response.last_delivered_at).DateTime.ToLocalTime() Expired = [bool]$response.expired ExpiresAt = [datetimeoffset]::FromUnixTimeSeconds($response.expires_at).DateTime.ToLocalTime() CalledBack = [bool]$response.called_back CalledBackAt = [datetimeoffset]::FromUnixTimeSeconds($response.called_back_at).DateTime.ToLocalTime() } } else { if ($null -ne $response.error) { Write-Error $response.error } elseif ($null -ne $response.errors) { foreach ($problem in $response.errors) { Write-Error $problem } } else { $response } } } } function Reset-PushoverConfig { [CmdletBinding(SupportsShouldProcess)] param () process { if ($PSCmdlet.ShouldProcess("PSPushover Module Configuration", "Reset to default")) { Write-Verbose "Using the default module configuration" $script:config = @{ PushoverApiDefaultUri = 'https://api.pushover.net/1' PushoverApiUri = 'https://api.pushover.net/1' DefaultAppToken = $null DefaultUserToken = $null } Save-PushoverConfig } } } function Send-Pushover { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory, Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [ValidateNotNullOrEmpty()] [string] $Message, [Parameter()] [string] $Title, [Parameter()] [byte[]] $Attachment, [Parameter()] [string] $FileName = 'attachment.jpg', [Parameter()] [ValidateNotNullOrEmpty()] [uri] $Url, [Parameter()] [ValidateNotNullOrEmpty()] [string] $UrlTitle, [Parameter()] [MessagePriority] $MessagePriority, [Parameter()] [ValidateScript({ if ($_.TotalSeconds -lt 30) { throw 'RetryInterval must be at least 30 seconds' } if ($_.TotalSeconds -gt 10800) { throw 'RetryInterval cannot exceed 3 hours' } $true })] [timespan] $RetryInterval = (New-TimeSpan -Minutes 1), [Parameter()] [ValidateScript({ if ($_.TotalSeconds -le 30) { throw 'ExpireAfter must be greater than the minimum RetryInterval value of 30 seconds' } if ($_.TotalSeconds -gt 10800) { throw 'ExpireAfter cannot exceed 3 hours' } $true })] [timespan] $ExpireAfter = (New-TimeSpan -Minutes 10), [Parameter()] [datetime] $Timestamp = (Get-Date), [Parameter()] [ValidateNotNullOrEmpty()] [string] $Sound, [Parameter()] [string[]] $Tags, [Parameter()] [ValidateNotNullOrEmpty()] [securestring] $Token, [Parameter()] [ValidateNotNullOrEmpty()] [securestring] $User, [Parameter()] [ValidateNotNullOrEmpty()] [string[]] $Device ) begin { $config = Get-PushoverConfig $uri = $config.ApiUri + '/messages.json' } process { if ($null -eq $Token) { $Token = $config.AppToken if ($null -eq $Token) { throw "Token not provided and no default application token has been set using Set-PushoverConfig." } } if ($null -eq $User) { $User = $config.UserToken if ($null -eq $User) { throw "User not provided and no default user id has been set using Set-PushoverConfig." } } $deviceList = if ($null -ne $Device) { [string]::Join(',', $Device) } else { $null } $tagList = if ($null -ne $Tags) { [string]::Join(',', $Tags) } else { $null } $body = [ordered]@{ token = $Token | ConvertTo-PlainText user = $User | ConvertTo-PlainText device = $deviceList title = $Title message = $Message url = $Url url_title = $UrlTitle priority = [int]$MessagePriority retry = [int]$RetryInterval.TotalSeconds expire = [int]$ExpireAfter.TotalSeconds timestamp = [int]([datetimeoffset]::new($Timestamp).ToUnixTimeMilliseconds() / 1000) tags = $tagList sound = $Sound } try { if ($Attachment.Length -eq 0) { $bodyJson = $body | ConvertTo-Json Write-Verbose "Message body:`r`n$($bodyJson.Replace($Body.token, "********").Replace($Body.user, "********"))" $response = Invoke-RestMethod -Method Post -Uri $uri -Body $bodyJson -ContentType application/json -UseBasicParsing } else { $response = Send-MessageWithAttachment -Body $body -Attachment $Attachment -FileName $FileName } } catch { Write-Verbose 'Handling HTTP error in Invoke-RestMethod response' $statusCode = $_.Exception.Response.StatusCode.value__ Write-Verbose "HTTP status code $statusCode" if ($statusCode -lt 400 -or $statusCode -gt 499) { throw } try { Write-Verbose 'Parsing HTTP request error response' $stream = $_.Exception.Response.GetResponseStream() $reader = [io.streamreader]::new($stream) $response = $reader.ReadToEnd() | ConvertFrom-Json if ([string]::IsNullOrWhiteSpace($response)) { throw $_ } Write-Verbose "Response body:`r`n$response" } finally { $reader.Dispose() } } if ($response.status -ne 1) { if ($null -ne $response.error) { Write-Error $response.error } elseif ($null -ne $response.errors) { foreach ($problem in $response.errors) { Write-Error $problem } } else { $response } } if ($null -ne $response.receipt) { Write-Output $response.receipt } } } function Set-PushoverConfig { [CmdletBinding(SupportsShouldProcess)] param ( [Parameter()] [uri] $ApiUri, [Parameter(ParameterSetName = 'AsPlainText')] [securestring] $Token, [Parameter()] [securestring] $User, [Parameter()] [switch] $Temporary ) process { if ($PSBoundParameters.ContainsKey('ApiUri')) { if ($PSCmdlet.ShouldProcess("Pushover ApiUri", "Set value to '$ApiUri'")) { $script:config.PushoverAPiUri = $ApiUri.ToString() } } if ($PSBoundParameters.ContainsKey('Token')) { if ($PSCmdlet.ShouldProcess("Pushover Default Application Token", "Set value")) { $script:config.DefaultAppToken = $Token } } if ($PSBoundParameters.ContainsKey('User')) { if ($PSCmdlet.ShouldProcess("Pushover Default User Key", "Set value")) { $script:config.DefaultUserToken = $User } } if (-not $Temporary) { Save-PushoverConfig } } } function Test-PushoverUser { [CmdletBinding()] [OutputType([PSPushoverUserValidation])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [securestring] $Token, [Parameter()] [ValidateNotNullOrEmpty()] [securestring] $User, [Parameter()] [ValidateNotNullOrEmpty()] [string] $Device, [Parameter()] [PSPushoverInformationLevel] $InformationLevel = [PSPushoverInformationLevel]::Detailed ) begin { $config = Get-PushoverConfig $uri = $config.ApiUri + '/users/validate.json' } process { if ($null -eq $Token) { $Token = $config.AppToken if ($null -eq $Token) { throw "Token not provided and no default application token has been set using Set-PushoverConfig." } } if ($null -eq $User) { $User = $config.UserToken if ($null -eq $User) { throw "User not provided and no default user id has been set using Set-PushoverConfig." } } $body = [ordered]@{ token = $Token | ConvertTo-PlainText user = $User | ConvertTo-PlainText device = $Device } try { $bodyJson = $body | ConvertTo-Json Write-Verbose "Message body:`r`n$($bodyJson.Replace($Body.token, "********").Replace($Body.user, "********"))" if (Get-Command Invoke-RestMethod -ParameterName SkipHttpErrorCheck -ErrorAction SilentlyContinue) { $response = Invoke-RestMethod -Method Post -Uri $uri -Body $bodyJson -ContentType application/json -SkipHttpErrorCheck } else { $response = Invoke-RestMethod -Method Post -Uri $uri -Body $bodyJson -ContentType application/json -UseBasicParsing } } catch { Write-Verbose 'Handling HTTP error in Invoke-RestMethod response' $statusCode = $_.Exception.Response.StatusCode.value__ Write-Verbose "HTTP status code $statusCode" if ($statusCode -lt 400 -or $statusCode -gt 499) { throw } try { Write-Verbose 'Parsing HTTP request error response' $stream = $_.Exception.Response.GetResponseStream() $reader = [io.streamreader]::new($stream) $response = $reader.ReadToEnd() | ConvertFrom-Json if ([string]::IsNullOrWhiteSpace($response)) { throw $_ } Write-Verbose "Response body:`r`n$response" } finally { $reader.Dispose() } } if ($null -ne $response.status) { switch ($InformationLevel) { ([PSPushoverInformationLevel]::Quiet) { Write-Output ($response.status -eq 1) } ([PSPushoverInformationLevel]::Detailed) { [PSPushoverUserValidation]@{ Valid = $response.status -eq 1 IsGroup = $response.group -eq 1 Devices = $response.devices Licenses = $response.licenses Error = $response.errors | Select-Object -First 1 } } Default { throw "InformationLevel $InformationLevel not implemented." } } } else { Write-Error "Unexpected response: $($response | ConvertTo-Json)" } } } function Wait-Pushover { [CmdletBinding()] [OutputType([PSPushoverNotificationStatus])] param ( [Parameter()] [ValidateNotNullOrEmpty()] [securestring] $Token, [Parameter(Mandatory, ValueFromPipeline)] [string] $Receipt, [Parameter()] [ValidateRange(5, 10800)] [int] $Interval = 10 ) begin { $config = Get-PushoverConfig } process { if ($null -eq $Token) { $Token = $config.Token if ($null -eq $Token) { throw "Token not provided and no default application token has been set using Set-PushoverConfig." } } $timeoutAt = (Get-Date).AddHours(3) while ((Get-Date) -lt $timeoutAt.AddSeconds($Interval)) { $status = Get-PushoverStatus -Token $Token -Receipt $Receipt -ErrorAction Stop $timeoutAt = $status.ExpiresAt if ($status.Acknowledged -or $status.Expired) { break } Start-Sleep -Seconds $Interval } Write-Output $status } } # Dot source public/private functions when importing from source if (Test-Path -Path $PSScriptRoot/Public) { $classes = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Classes/*.ps1') -Recurse -ErrorAction Stop) $public = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Public/*.ps1') -Recurse -ErrorAction Stop) $private = @(Get-ChildItem -Path (Join-Path -Path $PSScriptRoot -ChildPath 'Private/*.ps1') -Recurse -ErrorAction Stop) foreach ($import in @(($classes + $public + $private))) { try { . $import.FullName } catch { throw "Unable to dot source [$($import.FullName)]" } } Export-ModuleMember -Function $public.Basename } $script:PushoverApiDefaultUri = 'https://api.pushover.net/1' $script:PushoverApiUri = $script:PushoverApiDefaultUri $appDataRoot = [environment]::GetFolderPath([System.Environment+SpecialFolder]::ApplicationData) $script:configPath = Join-Path $appDataRoot 'joshooaj.PSPushover\config.xml' $script:config = $null if (-not (Import-PushoverConfig)) { Reset-PushoverConfig } $soundsCompleter = { param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $soundList = @('incoming', 'pianobar', 'climb', 'gamelan', 'bugle', 'vibrate', 'pushover', 'cosmic', 'spacealarm', 'updown', 'none', 'persistent', 'cashregister', 'mechanical', 'bike', 'classical', 'falling', 'alien', 'magic', 'siren', 'tugboat', 'intermission', 'echo') $soundList | Where-Object { $_ -like "$wordToComplete*" } | Foreach-Object { "'$_'" } } Register-ArgumentCompleter -CommandName Send-Pushover -ParameterName Sound -ScriptBlock $soundsCompleter |