PSDsHook.psm1

#Setup default paths for module in user home dir

$script:separator = [IO.Path]::DirectorySeparatorChar

switch ($PSVersionTable.PSEdition) {

    'Desktop' {

        $userDir = $env:USERPROFILE

    }

    'Core' {

        switch ($PSVersionTable.Platform) {

            'Win32NT' {
        
                $userDir = $env:USERPROFILE
        
            }
        
            'Unix' {
        
                $userDir = $env:HOME
        
            }
        }
    }
}

$script:defaultPsDsDir = (Join-Path -Path $userDir -ChildPath '.psdshook')
$script:configDir      = "$($defaultPsDsDir)$($separator)configs"
class DiscordColor {
    [int]$DecimalColor = $null
    [string]$HexColor  = [string]::Empty

    DiscordColor()
    {
        $embedColor = 8311585
        $this.HexColor     = "0x$([Convert]::ToString($embedColor, 16).ToUpper())"
        $this.DecimalColor = $embedColor
    }

    DiscordColor([int]$hex)
    {
        $this.DecimalColor = $hex
        $this.HexColor     = "0x$([Convert]::ToString($hex, 16).ToUpper())"
    }

    DiscordColor([string]$color)
    {

        [int]$embedColor = $null

        try {

            $embedColor = $color

        }
        catch {
            switch ($Color) {

                'blue' {

                    $embedColor = 4886754
                }

                'red' {

                    $embedColor = 13632027

                }

                'orange' {

                    $embedColor = 16098851

                }

                'yellow' {

                    $embedColor = 16312092

                }

                'brown' {

                    $embedColor = 9131818

                }

                'lightGreen' {

                    $embedColor = 8311585

                }

                'green' {

                    $embedColor = 4289797

                }

                'pink' {

                    $embedColor = 12390624

                }

                'purple' {

                    $embedColor = 9442302

                }

                'black' {

                    $embedColor = 1
                }

                'white' {

                    $embedColor = 16777215

                }

                'gray' {

                    $embedColor = 10197915

                }

                default {

                    $embedColor = 1

                }
            }
        }

        $this.HexColor     = "0x$([Convert]::ToString($embedColor, 16).ToUpper())"
        $this.DecimalColor = $embedColor

    }

    DiscordColor(
        [int]$r, 
        [int]$g, 
        [int]$b
    )
    {
        $this.DecimalColor = $this.ConvertFromRgb($r, $g, $b)
    }

    [string]ConvertFromHex([string]$hex)
    {
        [int]$decimalValue = [Convert]::ToDecimal($hex)

        return $decimalValue
    }

    [string]ConvertFromRgb(
        [int]$r, 
        [int]$g, 
        [int]$b
    )
    {
        $hexR = [Convert]::ToString($r, 16).ToUpper()
        if ($hexR.Length -eq 1)
        {
            $hexR = "0$hexR"
        }

        $hexG = [Convert]::ToString($g, 16).ToUpper()
        if ($hexG.Length -eq 1)
        {
            $hexG = "0$hexG"
        }

        $hexB = [Convert]::ToString($b, 16).ToUpper()
        if ($hexB.Length -eq 1)
        {
            $hexB = "0$hexB"
        }

        [string]$hexValue     = "0x$hexR$hexG$hexB"
        $this.HexColor        = $HexValue
        [string]$decimalValue = $this.ConvertFromHex([int]$hexValue)

        return $decimalValue
    }

    [string]ToString()
    {
        return $this.DecimalColor
    }
}
class DiscordConfig {
    [string]$HookUrl = [string]::Empty

    DiscordConfig([string]$configPath)
    {               
        $this.ImportConfig($configPath)    
    }

    DiscordConfig(
        [string]$url, 
        [string]$path
    )
    {
        $this.HookUrl      = $url      
        $this.ExportConfig($path)
    }

    [void]ExportConfig([string]$path)
    {
        Write-Verbose "Exporting configuration information to -> [$path]"

        $folderPath = Split-Path -Path $path

        if (!(Test-Path -Path $folderPath))
        {
            Write-Verbose "Creating folder -> [$folderPath]"
            New-Item -ItemType Directory -Path $folderPath            
        }

        $this | ConvertTo-Json | Out-File -FilePath $path
    }

    [void]ImportConfig([string]$configPath)
    {    
        Write-Verbose "Importing configuration from -> [$configPath]"

        $configSettings = Get-Content -Path $configPath -ErrorAction Stop | ConvertFrom-Json

        $this.HookUrl = $configSettings.HookUrl 
    }
}
class DiscordField {    
    [string]$name
    [string]$value
    [bool]$inline = $false

    DiscordField(
        [string]$name, 
        [string]$value
    )
    {
        $this.name  = $name
        $this.value = $value
    }

    DiscordField(
        [string]$name, 
        [string]$value, 
        [bool]$inline
    )
    {
        $this.name   = $name
        $this.value  = $value
        $this.inline = $inline
    }
}
class DiscordThumbnail {
    [string]$url = [string]::Empty
    [int]$width  = $null
    [int]$height = $null

    DiscordThumbnail([string]$url)
    {
        if ([string]::IsNullOrEmpty($url))
        {
            Write-Error "Please provide a url!"
        }
        else
        {            
            $this.url = $url
        }
    }

    DiscordThumbnail(
            [int]$width, 
            [int]$height, 
            [string]$url
    )
    {
        if ([string]::IsNullOrEmpty($url))
        {
            Write-Error "Please provide a url!"
        }
        else
        {
            $this.url    = $url
            $this.height = $height
            $this.width  = $width
        }
    }
}
class DiscordImage {    
    [string]$url      = [string]::Empty
    [string]$proxyUrl = [string]::Empty
    [int]$width       = $null
    [int]$height      = $null

    DiscordImage([string]$url)
    {
        if ([string]::IsNullOrEmpty($url))
        {
            Write-Error "Please provide a url!"
        }
        else
        {            
            $this.url = $url
        }
    }

    DiscordImage(   
        [string]$url,         
        [string]$proxyUrl
    )
    {
        if ([string]::IsNullOrEmpty($url) -and [string]::IsNullOrEmpty($proxyUrl))
        {
            Write-Error "Please provide: a url and proxyurl"
        }
        else
        {
            $this.url      = $url
            $this.proxyUrl = $proxyUrl
        }
    }

    DiscordImage(
        [string]$url,         
        [string]$proxyUrl,
        [int]$width, 
        [int]$height
    )
    {
        if (
            [string]::IsNullOrEmpty($url)      -and 
            [string]::IsNullOrEmpty($proxyUrl) -and
            !$width -and !($height)
        )
        {
            Write-Error "Please provide: a url and proxyurl"
        }
        else
        {
            $this.url      = $url
            $this.proxyUrl = $proxyUrl        
            $this.height   = $height
            $this.width    = $width
        }
    }
}

class DiscordAuthor {
    [string]$name           = [string]::Empty
    [string]$url            = [string]::Empty
    [string]$icon_url       = [string]::Empty
    [string]$proxy_icon_url = [string]::Empty

    DiscordAuthor([string]$name)
    {
        if ([string]::IsNullOrEmpty($name))
        {
            Write-Error "Please provide a name!"
        }
        else
        {            
            $this.name = $name
        }
    }

    DiscordAuthor(
        [string]$name, 
        [string]$icon_url
    )
    {
        if ([string]::IsNullOrEmpty($name))
        {
            Write-Error "Please provide a name and icon url"
        }
        else
        {
            $this.name       = $name
            $this.'icon_url' = $icon_url
        }
    }
}
class DiscordFooter {
    [string]$text           = [string]::Empty
    [string]$icon_url       = [string]::Empty
    [string]$proxy_icon_url = [string]::Empty

    DiscordFooter([string]$text)
    {
        if ([string]::IsNullOrEmpty($text))
        {
            Write-Error "Please provide some footer text!"
        }
        else
        {            
            $this.text = $text
        }
    }

    DiscordFooter(
        [string]$text, 
        [string]$icon_url
    )
    {
        if ([string]::IsNullOrEmpty($text))
        {
            Write-Error "Please provide some text and an icon url"
        }
        else
        {
            $this.text       = $text
            $this.'icon_url' = $icon_url
        }
    }
}
class DiscordEmbed {
    [string]$title                        = [string]::Empty
    [string]$description                  = [string]::Empty
    [System.Collections.ArrayList]$fields = @()
    [string]$color                        = [DiscordColor]::New().ToString()   
    $thumbnail                            = [string]::Empty
    $image                                = [string]::Empty
    $author                               = [string]::Empty
    $footer                               = [string]::Empty
    $url                                  = [string]::Empty

    DiscordEmbed()
    {
        Write-Error "Please provide a title and description (and optionally, a color)!"
    }

    DiscordEmbed(
        [string]$embedTitle, 
        [string]$embedDescription
    )
    {
        $this.title       = $embedTitle
        $this.description = $embedDescription
    }

    DiscordEmbed(
        [string]      $embedTitle, 
        [string]      $embedDescription, 
        [DiscordColor]$embedColor
    )
    {
        $this.title       = $embedTitle
        $this.description = $embedDescription
        $this.color       = $embedColor.ToString()
    }

    [void]AddField($field) 
    {
        if ($field.PsObject.TypeNames[0] -eq 'DiscordField')
        {
            Write-Verbose "Adding field to field array!"
            $this.Fields.Add($field) | Out-Null
        } 
        else
        {
            Write-Error "Did not receive a [DiscordField] object!"
        }
    }

    [void]AddThumbnail($thumbNail)
    {
        if ($thumbNail.PsObject.TypeNames[0] -eq 'DiscordThumbnail')
        {
            $this.thumbnail = $thumbNail
        } 
        else 
        {
            Write-Error "Did not receive a [DiscordThumbnail] object!"
        }
    }

    [void]AddImage($image)
    {
        if ($image.PsObject.TypeNames[0] -eq 'DiscordImage')
        {
            $this.image = $image
        } 
        else 
        {
            Write-Error "Did not receive a [DiscordImage] object!"
        }
    }

    [void]AddAuthor($author)
    {
        if ($author.PsObject.TypeNames[0] -eq 'DiscordAuthor')
        {
            $this.author = $author
        } 
        else 
        {
            Write-Error "Did not receive a [DiscordAuthor] object!"
        }
    }

    [void]AddFooter($footer)
    {
        if ($footer.PsObject.TypeNames[0] -eq 'DiscordFooter')
        {
            $this.footer = $footer
        } 
        else 
        {
            Write-Error "Did not receive a [DiscordFooter] object!"
        }
    }

    [void]WithUrl($url)
    {
        if (![string]::IsNullOrEmpty($url))
        {
            $this.url = $url
        } 
        else 
        {
            Write-Error "Please provide a url!"
        }
    }

    [void]WithColor([DiscordColor]$color)
    {
        $this.color = $color
    }
    
    [System.Collections.ArrayList] ListFields()
    {
        return $this.Fields
    }
}
class DiscordFile {

    [string]$FilePath                                  = [string]::Empty
    [string]$FileName                                  = [string]::Empty
    [string]$FileTitle                                 = [string]::Empty
    [System.Net.Http.MultipartFormDataContent]$Content = [System.Net.Http.MultipartFormDataContent]::new()
    [System.IO.FileStream]$Stream                      = $null

    DiscordFile([string]$FilePath)
    {
        $this.FilePath  = $FilePath
        $this.FileName  = Split-Path $filePath -Leaf
        $this.fileTitle = $this.FileName.Substring(0,$this.FileName.LastIndexOf('.'))
        $fileContent = $this.GetFileContent($FilePath)
        $this.Content.Add($fileContent)                 
    }

    [System.Net.Http.StreamContent]GetFileContent($filePath)
    {        
        $fileStream                             = [System.IO.FileStream]::new($filePath, [System.IO.FileMode]::Open)
        $fileHeader                             = [System.Net.Http.Headers.ContentDispositionHeaderValue]::new("form-data")
        $fileHeader.Name                        = $this.fileTitle
        $fileHeader.FileName                    = $this.FileName
        $fileContent                            = [System.Net.Http.StreamContent]::new($fileStream)        
        $fileContent.Headers.ContentDisposition = $fileHeader
        $fileContent.Headers.ContentType        = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("text/plain")   
                        
        $this.stream = $fileStream
        return $fileContent        
    }    
}
function Invoke-PayloadBuilder {
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory
        )]
        $PayloadObject
    )
    
    process {

        $type = $PayloadObject | Get-Member | Select-Object -ExpandProperty TypeName -Unique
    
        switch ($type) {
                        
            'DiscordEmbed' {

                [bool]$createArray = $true

                #check if array
                $PayloadObject.PSObject.TypeNames | ForEach-Object {

                    switch ($_) {

                        {$_ -match '^System\.Collections\.Generic\.List.+'} {
                            
                            $createArray = $false

                        }

                        'System.Array' {

                            $createArray = $false

                        }

                        'System.Collections.ArrayList' {
                            
                            $createArray = $false

                        }
                    }
                }

                if (!$createArray) {

                    $payload = [PSCustomObject]@{

                        embeds = $PayloadObject
    
                    }

                } else {

                    $embedArray = New-Object 'System.Collections.Generic.List[DiscordEmbed]'
                    $embedArray.Add($PayloadObject) | Out-Null

                    $payload = [PSCustomObject]@{

                        embeds = $embedArray

                    }
                }
            }

            'System.String' {

                if (Test-Path $PayloadObject -ErrorAction SilentlyContinue) {

                    $payload = [DiscordFile]::New($payloadObject)

                } else {

                    $payload = [PSCustomObject]@{

                        content = ($PayloadObject | Out-String)

                    }
                }                
            }
        }
    }
    
    end {

        return $payload

    }
}

function Invoke-PSDsHook {
    <#
    .SYNOPSIS
    Invoke-PSDsHook
    Use PowerShell classes to make using Discord Webhooks easy and extensible

    .DESCRIPTION
    This function allows you to use Discord Webhooks with embeds, files, and various configuration settings

    .PARAMETER CreateConfig
    If specified, will create a configuration file containing the webhook URL as the argument.
    You can use the ConfigName parameter to create another configuration separate from the default.

    .PARAMETER WebhookUrl
    If used with an embed or file, this URL will be used in the webhook call.

    .PARAMETER ConfigName
    Specified a name for the configuration file.
    Can be used when creating a configuration file, as well as when passing embeds/files.

    .PARAMETER ListConfigs
    Lists configuration files

    .PARAMETER EmbedObject
    Accepts an array of [EmbedObject]'s to pass in the webhook call.

    .EXAMPLE
    (Create a configuration file)
    Configuration files are stored in a sub directory of your user's home directory named .psdshook/configs

    Invoke-PsDsHook -CreateConfig "www.hook.com/hook"
    .EXAMPLE
    (Create a configuration file with a non-standard name)
    Configuration files are stored in a sub directory of your user's home directory named .psdshook/configs

    Invoke-PsDsHook -CreateConfig "www.hook.com/hook2" -ConfigName 'config2'

    .EXAMPLE
    (Send an embed with the default config)

    using module PSDsHook

    If the module is not in one of the folders listed in ($env:PSModulePath -split "$([IO.Path]::PathSeparator)")
    You must specify the full path to the psm1 file in the above using statement
    Example: using module 'C:\users\thegn\repos\PsDsHook\out\PSDsHook\0.0.1\PSDsHook.psm1'

    Create embed builder object via the [DiscordEmbed] class
    $embedBuilder = [DiscordEmbed]::New(
                        'title',
                        'description'
                    )

    Add blue color
    $embedBuilder.WithColor(
        [DiscordColor]::New(
                'blue'
        )
    )
    
    Finally, call the function that will send the embed array to the webhook url via the default configuraiton file
    Invoke-PSDsHook $embedBuilder -Verbose

    .EXAMPLE
    (Send an webhook with just text)

    Invoke-PSDsHook -HookText 'this is the webhook message' -Verbose
    #>
    
    [cmdletbinding()]
    param(
        [Parameter(
            ParameterSetName = 'createDsConfig'
        )]
        [string]
        $CreateConfig,

        [Parameter(
        )]
        [string]
        $WebhookUrl,

        [Parameter(
            Mandatory,
            ParameterSetName = 'file'
        )]
        [string]
        $FilePath,

        [Parameter(

        )]
        [string]
        $ConfigName = 'config',

        [Parameter(
            ParameterSetName = 'configList'
        )]
        [switch]
        $ListConfigs,

        [Parameter(
            ParameterSetName = 'embed',
            Position = 0
        )]
        $EmbedObject,

        [Parameter(
            ParameterSetName = 'simple'
        )]
        [string]
        $HookText
    )

    begin {            

        #Create full path to the configuration file
        $configPath = "$($configDir)$($separator)$($ConfigName).json"
                    
        #Ensure we can access the path, and error out if we cannot
        if (!(Test-Path -Path $configPath -ErrorAction SilentlyContinue) -and !$CreateConfig -and !$WebhookUrl) {

            throw "Unable to access [$configPath]. Please provide a valid configuration name. Use -ListConfigs to list configurations, or -CreateConfig to create one."

        } elseif (!$CreateConfig -and $WebhookUrl) {

            $hookUrl = $WebhookUrl

            Write-Verbose "Manual mode enabled..."

        } elseif ((!$CreateConfig -and !$WebhookUrl) -and $configPath) {

            #Get configuration information from the file specified
            $config = [DiscordConfig]::New($configPath)                
            $hookUrl = $config.HookUrl             

        }        
    }

    process {
            
        switch ($PSCmdlet.ParameterSetName) {

            'embed' {

                $payload = Invoke-PayloadBuilder -PayloadObject $EmbedObject

                Write-Verbose "Sending:"
                Write-Verbose ""
                Write-Verbose ($payload | ConvertTo-Json -Depth 4)

                try {

                    Invoke-RestMethod -Uri $hookUrl -Body ($payload | ConvertTo-Json -Depth 4) -ContentType 'Application/Json' -Method Post

                }
                catch {

                    $errorMessage = $_.Exception.Message
                    throw "Error executing Discord Webhook -> [$errorMessage]!"

                }
            }

            'file' {

                if ($PSVersionTable.PSVersion.Major -lt 6) {

                    throw "Support for sending files is not yet available in PowerShell 5.x"
                    
                } else {

                    $fileInfo = Invoke-PayloadBuilder -PayloadObject $FilePath
                    $payload  = $fileInfo.Content
    
                    Write-Verbose "Sending:"
                    Write-Verbose ""
                    Write-Verbose ($payload | Out-String)
    
                    #If it is a file, we don't want to include the ContentType parameter as it is included in the body
                    try {
    
                        Invoke-RestMethod -Uri $hookUrl -Body $payload -Method Post
    
                    }
                    catch {
    
                        $errorMessage = $_.Exception.Message
                        throw "Error executing Discord Webhook -> [$errorMessage]!"
    
                    }
                    finally {
    
                        $fileInfo.Stream.Dispose()
                        
                    }
                } 
            }

            'simple' {

                $payload = Invoke-PayloadBuilder -PayloadObject $HookText

                Write-Verbose "Sending:"
                Write-Verbose ""
                Write-Verbose ($payload | ConvertTo-Json -Depth 4)

                try {
                    
                    Invoke-RestMethod -Uri $hookUrl -Body ($payload | ConvertTo-Json -Depth 4) -ContentType 'Application/Json' -Method Post

                }
                catch {

                    $errorMessage = $_.Exception.Message
                    throw "Error executing Discord Webhook -> [$errorMessage]!"

                }
            }

            'createDsConfig' {
                
                [DiscordConfig]::New($CreateConfig, $configPath)

            }

            'configList' {

                $configs = (Get-ChildItem -Path (Split-Path $configPath) | Where-Object {$PSitem.Extension -eq '.json'} | Select-Object -ExpandProperty Name)
                if ($configs) {

                    Write-Host "Configuration files in [$configDir]:"
                    return $configs

                } else {

                    Write-Host "No configuration files found in [$configDir]"

                }
            }
        }        
    }
}