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