poshbot.psm1


# Some enums
enum AccessRight {
    Allow
    Deny
}

enum ConnectionStatus {
    Connected
    Disconnected
}

enum TriggerType {
    Command
    Event
    Regex
    Timer
}

enum LogLevel {
    Info = 1
    Verbose = 2
    Debug = 4
}

enum LogSeverity {
    Normal
    Warning
    Error
}

class LogMessage {
    [datetime]$DateTime = (Get-Date)
    [string]$Severity = [LogSeverity]::Normal
    [string]$LogLevel = [LogLevel]::Info
    [string]$Message
    [object]$Data

    LogMessage() {
    }

    LogMessage([string]$Message) {
        $this.Message = $Message
    }

    LogMessage([string]$Message, [object]$Data) {
        $this.Message = $Message
        $this.Data = $Data
    }

    LogMessage([LogSeverity]$Severity, [string]$Message) {
        $this.Severity = $Severity
        $this.Message = $Message
    }

    LogMessage([LogSeverity]$Severity, [string]$Message, [object]$Data) {
        $this.Severity = $Severity
        $this.Message = $Message
        $this.Data = $Data
    }

    # Borrowed from https://github.com/PowerShell/PowerShell/issues/2736
    hidden [string]Compact([string]$Json) {
        $indent = 0
        $compacted = ($Json -Split '\n' | ForEach-Object {
            if ($_ -match '[\}\]]') {
                # This line contains ] or }, decrement the indentation level
                $indent--
            }
            $line = (' ' * $indent * 2) + $_.TrimStart().Replace(': ', ': ')
            if ($_ -match '[\{\[]') {
                # This line contains [ or {, increment the indentation level
                $indent++
            }
            $line
        }) -Join "`n"
        return $compacted
    }

    [string]ToJson() {
        $json = @{
            DataTime = $this.DateTime
            Severity = $this.Severity
            LogLevel = $this.LogLevel
            Message = $this.Message
            Data = $this.Data
        } | ConvertTo-Json -Depth 100 -Compress
        return $json
        #return $this.Compact($json)
    }

    [string]ToString() {
        return $this.ToJson()
    }
}

class Logger {

    # The log directory
    [string]$LogDir

    hidden [string]$LogFile

    # Out logging level
    # Any log messages less than or equal to this will be logged
    [LogLevel]$LogLevel

    # The max size for the log files before rolling
    [int]$MaxSizeMB = 10

    # Number of each log file type to keep
    [int]$FilesToKeep = 5

    # Create logs files under provided directory
    Logger([string]$LogDir, [LogLevel]$LogLevel) {
        $this.LogDir = $LogDir
        $this.LogLevel = $LogLevel
        $this.LogFile = Join-Path -Path $this.LogDir -ChildPath 'PoshBot.log'
        $this.CreateLogFile()
    }

    # Create new log file or roll old log
    hidden [void]CreateLogFile() {
        if (Test-Path -Path $this.LogFile) {
            $this.RollLog($this.LogFile, $true)
        }
        Write-Debug -Message "[Logger:Logger] Creating log file [$($this.LogFile)]"
        New-Item -Path $this.LogFile -ItemType File -Force
    }

    [void]Info([LogMessage]$Message) {
        $Message.LogLevel = [LogLevel]::Info
        $this.Log($Message)
    }

    [void]Verbose([LogMessage]$Message) {
        $Message.LogLevel = [LogLevel]::Verbose
        $this.Log($Message)
    }

    [void]Debug([LogMessage]$Message) {
        $Message.LogLevel = [LogLevel]::Debug
        $this.Log($Message)
    }

    # Write out message in JSON form to log file
    [void]Log([LogMessage]$Message) {

        if ($global:VerbosePreference -eq 'Continue') {
            Write-Verbose -Message $Message.ToJson()
        } elseIf ($global:DebugPreference -eq 'Continue') {
            Write-Debug -Message $Message.ToJson()
        }

        if ($Message.LogLevel.value__ -le $This.LogLevel.value__) {
            $this.RollLog($this.LogFile, $false)
            $json = $Message.ToJson()
            $json | Out-File -FilePath $this.LogFile -Append -Encoding utf8
        }
    }

    # Checks to see if file in question is larger than the max size specified for the logger.
    # If it is, it will roll the log and delete older logs to keep our number of logs per log type to
    # our max specifiex in the logger.
    # Specified $Always = $true will roll the log regardless
    hidden [void]RollLog([string]$LogFile, [bool]$Always) {
        if (Test-Path -Path $LogFile) {
            if ((($file = Get-Item -Path $logFile) -and ($file.Length/1mb) -gt $this.MaxSizeMB) -or $Always) {
                # Remove the last item if it would go over the limit
                if (Test-Path -Path "$logFile.$($this.FilesToKeep)") {
                    Remove-Item -Path "$logFile.$($this.FilesToKeep)"
                }
                foreach ($i in $($this.FilesToKeep)..1) {
                    if (Test-path -Path "$logFile.$($i-1)") {
                        Move-Item -Path "$logFile.$($i-1)" -Destination "$logFile.$i"
                    }
                }
                Move-Item -Path $logFile -Destination "$logFile.$i"
                $null = New-Item -Path $LogFile -Type File -Force | Out-Null
            }
        }
    }
}

class ExceptionFormatter {
    static [string]ToJson([System.Management.Automation.ErrorRecord]$Exception) {
        $props = @(
            @{l = 'CommandName'; e = {$_.InvocationInfo.MyCommand.Name}}
            @{l = 'Message'; e = {$_.Exception.Message}}
            @{l = 'Position'; e = {$_.InvocationInfo.PositionMessage}}
            @{l = 'CategoryInfo'; e = {$_.CategoryInfo.ToString()}}
            @{l = 'FullyQualifiedErrorId'; e = {$_.FullyQualifiedErrorId}}
        )
        return $Exception | Select-Object -Property $props | ConvertTo-Json
    }
}

# This marks a class method as a bot command. When loading a plugin, the bot framework will
# look for methods decorated with this attribute. These methods will be the commands exposed
# to users of the bot
# class BotCommand : Attribute {
# [string]$Name # The name for the bot command
# [bool]$HideFromHelp = $false # Hides the command from help
# [bool]$KeepHistory = $true # Store history of command executes in bot history
# }

# An Event is something that happended on a chat network. A person joined a room, a message was received
# Really any notification from the chat network back to the bot is considered an Event of some sort
class Event {
    [string]$Type
    [string]$ChannelId
    [pscustomobject]$Data
}


# Represents a person on a chat network
class Person {

    [string]$Id

    # The identifier for the device or client the person is using
    [string]$ClientId

    [string]$Nickname
    [string]$FirstName
    [string]$LastName
    [string]$FullName

    [string]ToString() {
        return "$($this.id):$($this.NickName):$($this.FullName)"
    }
}

class Room {

    [string]$Id

    # The name of the room
    [string]$Name

    # The room topic
    [string]$Topic

    # Indicates if this room already exists or not
    [bool]$Exists

    # Indicates if this room has already been joined
    [bool]$Joined

    [hashtable]$Members = @{}

    Room() {}

    [string]Join() {
        throw 'Must Override Method'
    }

    [string]Leave() {
        throw 'Must Override Method'
    }

    [string]Create() {
        throw 'Must Override Method'
    }

    [string]Destroy() {
        throw 'Must Override Method'
    }

    [string]Invite([string[]]$Invitees) {
        throw 'Must Override Method'
    }
}

enum MessageType {
    ChannelRenamed
    Message
    PinAdded
    PinRemoved
    PresenceChange
    ReactionAdded
    ReactionRemoved
    StarAdded
    StarRemoved
}

enum MessageSubtype {
    None
    ChannelJoined
    ChannelLeft
    ChannelRenamed
    ChannelPurposeChanged
    ChannelTopicChanged
}

# A chat message that is received from the chat network
class Message {
    [string]$Id                 # MAY have this
    [MessageType]$Type = [MessageType]::Message
    [MessageSubtype]$Subtype = [MessageSubtype]::None    # Some messages have subtypes
    [string]$Text               # Text of message. This may be empty depending on the message type
    [string]$To
    [string]$From               # Who sent the message
    [hashtable]$Options         # Any other bits of information about a message. This will be backend specific
    [pscustomobject]$RawMessage # The raw message as received by the backend. This can be usefull for the backend
}

class UserEnterMessage : Message {}
class UserExitMessage : Message {}
class TopicChangeMessage : Message {}

enum Severity {
    Success
    Warning
    Error
    None
}

# A response message that is sent back to the chat network.
class Response {
    [Severity]$Severity = [Severity]::Success
    [string[]]$Text
    [string]$MessageFrom
    [string]$To
    [pscustomobject[]]$Data = @()
}

# A card is a special type of response with specific formatting
class Card : Response {
    [string]$Summary
    [string]$Title
    [string]$FallbackText
    [string]$Link
    [string]$ImageUrl
    [string]$ThumbnailUrl
    [hashtable]$Fields
}

class Stream {
    [object[]]$Debug = @()
    [object[]]$Error = @()
    [object[]]$Information = @()
    [object[]]$Verbose = @()
    [object[]]$Warning = @()
}

# Represents the result of running a command
class CommandResult {
    [bool]$Success
    [object[]]$Errors = @()
    [object[]]$Output = @()
    [Stream]$Streams = [Stream]::new()
    [bool]$Authorized = $true
    [timespan]$Duration
}

class ParsedCommand {
    [string]$CommandString
    [string]$Plugin = $null
    [string]$Command = $null
    [string[]]$Tokens = @()
    [hashtable]$NamedParameters = @{}
    [System.Collections.ArrayList]$PositionalParameters = (New-Object System.Collections.ArrayList)
    #[System.Management.Automation.FunctionInfo]$ModuleCommand = $null
    [Message]$OriginalMessage
}

class CommandParser {
    [ParsedCommand] static Parse([string]$CommandString, [Message]$OriginalMessage) {

        $CommandString = $CommandString.Trim()

        # The command is the first word of the message
        $command = $CommandString.Split(' ')[0]

        # The command COULD be in the form of <command> or <plugin:command>
        # Figure out which one
        $plugin = [string]::Empty
        if ($OriginalMessage.Type -eq [MessageType]::Message -and $OriginalMessage.SubType -eq [MessageSubtype]::None ) {
            $plugin = $command.Split(':')[0]
        }
        $command = $command.Split(':')[1]
        if (-not $command) {
            $command = $plugin
            $plugin = $null
        }

        $parsedCommand = [ParsedCommand]::new()
        $parsedCommand.CommandString = $CommandString
        $parsedCommand.Plugin = $plugin
        $parsedCommand.Command = $command
        $parsedCommand.OriginalMessage = $OriginalMessage

        # Parse parameters
        $tokens = $CommandString | Get-StringToken
        try {
            $r = ConvertFrom-ParameterToken -Tokens $Tokens
            $parsedCommand.Tokens = $r.Tokens
            $parsedCommand.NamedParameters = $r.NamedParameters
            $parsedCommand.PositionalParameters = $r.PositionalParameters
        } catch {
            Write-Error "Error parsing command [$CommandString]: $_"
        }

        return $parsedCommand
    }
}

class Permission {
    [string]$Name
    [string]$Plugin
    [string]$Description
    [bool]$Adhoc = $false

    Permission([string]$Name) {
        $this.Name = $Name
    }

    Permission([string]$Name, [string]$Plugin) {
        $this.Name = $Name
        $this.Plugin = $Plugin
    }

    Permission([string]$Name, [string]$Plugin, [string]$Description) {
        $this.Name = $Name
        $this.Plugin = $Plugin
        $this.Description = $Description
    }

    [hashtable]ToHash() {
        return @{
            Name = $this.Name
            Plugin = $this.Plugin
            Description = $this.Description
            Adhoc = $this.Adhoc
        }
    }

    [string]ToString() {
        return "$($this.Plugin):$($this.Name)"
    }
}

class CommandAuthorizationResult {
    [bool]$Authorized
    [string]$Message

    CommandAuthorizationResult() {
        $this.Authorized = $true
    }

    CommandAuthorizationResult([bool]$Authorized) {
        $this.Authorized = $Authorized
    }

    CommandAuthorizationResult([bool]$Authorized, [string]$Message) {
        $this.Authorized = $Authorized
        $this.Message = $Message
    }
}

# An access filter controls under what conditions a command can be run and who can run it.
class AccessFilter {

    [hashtable]$Permissions = @{}

    [CommandAuthorizationResult]Authorize([string]$PermissionName) {
        if ($this.Permissions.Count -eq 0) {
            return $true
        } else {
            if (-not $this.Permissions.ContainsKey($PermissionName)) {
                return [CommandAuthorizationResult]::new($false, "Permission [$PermissionName] is not authorized to execute this command")
            } else {
                return $true
            }
        }
    }

    [void]AddPermission([Permission]$Permission) {
        if (-not $this.Permissions.ContainsKey($Permission.ToString())) {
            $this.Permissions.Add($Permission.ToString(), $Permission)
        }
    }

    [void]RemovePermission([Permission]$Permission) {
        if ($this.Permissions.ContainsKey($Permission.ToString())) {
            $this.Permissions.Remove($Permission.ToString())
        }
    }
}

class Role {
    [string]$Name
    [string]$Description
    [hashtable]$Permissions = @{}

    Role([string]$Name) {
        $this.Name = $Name
    }

    Role([string]$Name, [string]$Description) {
        $this.Name = $Name
        $this.Description = $Description
    }

    [void]AddPermission([Permission]$Permission) {
        if (-not $this.Permissions.ContainsKey($Permission.ToString())) {
            $this.Permissions.Add($Permission.ToString(), $Permission)
        }
    }

    [void]RemovePermission([Permission]$Permission) {
        if ($this.Permissions.ContainsKey($Permission.ToString())) {
            $this.Permissions.Remove($Permission.ToString())
        }
    }

    [hashtable]ToHash() {
        return @{
            Name = $this.Name
            Description = $this.Description
            Permissions = @($this.Permissions.Keys)
        }
    }
}

# A group contains a collection of users and a collection of roles
# those users will be a member of
class Group {
    [string]$Name
    [string]$Description
    [hashtable]$Users = @{}
    [hashtable]$Roles = @{}

    Group([string]$Name) {
        $this.Name = $Name
    }

    Group([string]$Name, [string]$Description) {
        $this.Name = $Name
        $this.Description = $Description
    }

    [void]AddRole([Role]$Role) {
        if (-not $this.Roles.ContainsKey($Role.Name)) {
            $this.Roles.Add($Role.Name, $Role)
        }
    }

    [void]RemoveRole([Role]$Role) {
        if ($this.Roles.ContainsKey($Role.Name)) {
            $this.Roles.Remove($Role.Name)
        }
    }

    [void]AddUser([string]$Username) {
        if (-not $this.Users.ContainsKey($Username)) {
            Write-Verbose "[Group: AddUser] Adding user [$Username] to [$($this.Name)]"
            $this.Users.Add($Username, $null)
        } else {
            Write-Verbose "[Group: AddUser] User [$Username] is already in [$($this.Name)]"
        }
    }

    [void]RemoveUser([string]$Username) {
        if ($this.Users.ContainsKey($Username)) {
            $this.Users.Remove($Username)
        }
    }

    [hashtable]ToHash() {
        return @{
            Name = $this.Name
            Description = $this.Description
            Users = $this.Users.Keys
            Roles = $this.Roles.Keys
        }
    }
}

class Trigger {
    [TriggerType]$Type
    [string]$Trigger
    [MessageType]$MessageType = [MessageType]::Message
    [MessageSubType]$MessageSubtype = [Messagesubtype]::None

    Trigger([TriggerType]$Type, [string]$Trigger) {
        $this.Type = $Type
        $this.Trigger = $Trigger
    }
}
#requires -Modules Configuration

class StorageProvider {

    [string]$ConfigPath

    StorageProvider() {
        $this.ConfigPath = (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot')
    }

    StorageProvider([string]$Dir) {
        $this.ConfigPath = $Dir
    }

    [hashtable]GetConfig([string]$ConfigName) {
        $path = Join-Path -Path $this.ConfigPath -ChildPath "$ConfigName.psd1"
        if (Test-Path -Path $path) {
            $config = Get-Content -Path $path -Raw | ConvertFrom-Metadata
            return $config
        } else {
            Write-Error "Configuration [$path] not found"
            return $null
        }
    }

    [void]SaveConfig([string]$ConfigName, [hashtable]$Config) {
        $path = Join-Path -Path $this.ConfigPath -ChildPath "$ConfigName.psd1"
        $meta = $config | ConvertTo-Metadata
        if (-not (Test-Path -Path $path)) {
            New-Item -Path $Path -ItemType File
        }
        $meta | Out-file -FilePath $path -Force -Encoding utf8
    }
}

class RoleManager {
    [hashtable]$Groups = @{}
    [hashtable]$Permissions = @{}
    [hashtable]$Roles = @{}
    [hashtable]$RoleUserMapping = @{}
    hidden [object]$_Backend
    hidden [StorageProvider]$_Storage
    hidden [Logger]$_Logger

    RoleManager([object]$Backend, [StorageProvider]$Storage, [Logger]$Logger) {
        $this._Backend = $Backend
        $this._Storage = $Storage
        $this._Logger = $Logger
        $this.Initialize()
    }

    [void]Initialize() {
        # Load in state from persistent storage
        $this._Logger.Info([LogMessage]::new('[RoleManager:Initialize] Initializing'))

        # Create the builtin Admin role and add all the permissions defined
        # in the [Builtin] module
        $adminrole = [Role]::New('Admin', 'Bot administrator role')

        # TODO
        # Get these from the builtin module manifest rather than hard coding them here
        @(
            'manage-roles'
            'show-help'
            'view'
            'view-role'
            'view-group'
            'manage-plugins'
            'manage-groups'
            'manage-permissions'
        ) | foreach-object {
            $p = [Permission]::new($_, 'Builtin')
            $adminRole.AddPermission($p)
        }
        $this.Roles.Add($adminRole.Name, $adminRole)

        # Creat the builtin [Admin] group and add the [Admin role] to it
        $adminGroup = [Group]::new('Admin', 'Bot administrators')
        $adminGroup.AddRole($adminRole)
        $this.Groups.Add($adminGroup.Name, $adminGroup)

        $this.LoadState()
    }

    # TODO
    # Save state to storage
    [void]SaveState() {
        $this._Logger.Verbose([LogMessage]::new("[RoleManager:SaveState] Saving role manager state to storage"))

        $permissionsToSave = @{}
        foreach ($permission in $this.Permissions.GetEnumerator()) {
            $permissionsToSave.Add($permission.Name, $permission.Value.ToHash())
        }
        $this._Storage.SaveConfig('permissions', $permissionsToSave)

        $rolesToSave = @{}
        foreach ($role in $this.Roles.GetEnumerator()) {
            $rolesToSave.Add($role.Name, $role.Value.ToHash())
        }
        $this._Storage.SaveConfig('roles', $rolesToSave)

        $groupsToSave = @{}
        foreach ($group in $this.Groups.GetEnumerator()) {
            $groupsToSave.Add($group.Name, $group.Value.ToHash())
        }
        $this._Storage.SaveConfig('groups', $groupsToSave)
    }

    # TODO
    # Load state from storage
    [void]LoadState() {
        $this._Logger.Verbose([LogMessage]::new("[RoleManager:LoadState] Loading role manager state from storage"))

        $permissionConfig = $this._Storage.GetConfig('permissions')
        if ($permissionConfig) {
            foreach($permKey in $permissionConfig.Keys) {
                $perm = $permissionConfig[$permKey]
                $p = [Permission]::new($perm.Name, $perm.Plugin)
                if ($perm.Adhoc) {
                    $p.Adhoc = $perm.Adhoc
                }
                if ($perm.Description) {
                    $p.Description = $perm.Description
                }
                if (-not $this.Permissions.ContainsKey($p.ToString())) {
                    $this.Permissions.Add($p.ToString(), $p)
                }
            }
        }

        $roleConfig = $this._Storage.GetConfig('roles')
        if ($roleConfig) {
            foreach ($roleKey in $roleConfig.Keys) {
                $role = $roleConfig[$roleKey]
                $r = [Role]::new($roleKey)
                if ($role.Description) {
                    $r.Description = $role.Description
                }
                if ($role.Permissions) {
                    foreach ($perm in $role.Permissions) {
                        if ($p = $this.Permissions[$perm]) {
                            $r.AddPermission($p)
                        }
                    }
                }
                if (-not $this.Roles.ContainsKey($r.Name)) {
                    $this.Roles.Add($r.Name, $r)
                }
            }
        }

        $groupConfig = $this._Storage.GetConfig('groups')
        if ($groupConfig) {
            foreach ($groupKey in $groupConfig.Keys) {
                $group = $groupConfig[$groupKey]
                $g = [Group]::new($groupKey)
                if ($group.Description) {
                    $g.Description = $group.Description
                }
                if ($group.Users) {
                    foreach ($u in $group.Users) {
                        $g.AddUser($u)
                    }
                }
                if ($group.Roles) {
                    foreach ($r in $group.Roles) {
                        if ($ro = $this.GetRole($r)) {
                            $g.AddRole($ro)
                        }
                    }
                }
                if (-not $this.Groups.ContainsKey($g.Name)) {
                    $this.Groups.Add($g.Name, $g)
                }
            }
        }
    }

    [Group]GetGroup([string]$Groupname) {
        if ($g = $this.Groups[$Groupname]) {
            return $g
        } else {
            $msg = "[RoleManager:GetGroup] Group [$Groupname] not found"
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, $msg))
            Write-Error -Message $msg
            return $null
        }
    }

    [void]UpdateGroupDescription([string]$Groupname, [string]$Description) {
        if ($g = $this.Groups[$Groupname]) {
            $g.Description = $Description
            $this.SaveState()
        } else {
            $msg = "[RoleManager:UpdateGroupDescription] Group [$Groupname] not found"
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, $msg))
            Write-Error -Message $msg
        }
    }

    [void]UpdateRoleDescription([string]$Rolename, [string]$Description) {
        if ($r = $this.Roles[$Rolename]) {
            $r.Description = $Description
            $this.SaveState()
        } else {
            $msg = "[RoleManager:UpdateRoleDescription] Role [$Rolename] not found"
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, $msg))
            Write-Error -Message $msg
        }
    }

    [Permission]GetPermission([string]$PermissionName) {
        $p = $this.Permissions[$PermissionName]
        if ($p) {
            return $p
        } else {
            $msg = "[RoleManager:GetPermission] Permission [$PermissionName] not found"
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, $msg))
            Write-Error -Message $msg
            return $null
        }
    }

    [Role]GetRole([string]$RoleName) {
        $r = $this.Roles[$RoleName]
        if ($r) {
            return $r
        } else {
            $msg = "[RoleManager:GetRole] Role [$RoleName] not found"
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, $msg))
            Write-Error -Message $msg
            return $null
        }
    }

    [void]AddGroup([Group]$Group) {
        if (-not $this.Groups.ContainsKey($Group.Name)) {
            $this._Logger.Info([LogMessage]::new("[RoleManager:AddGroup] Adding group [$($Group.Name)]"))
            $this.Groups.Add($Group.Name, $Group)
            $this.SaveState()
        } else {
            $this._Logger.Info([LogMessage]::new("[RoleManager:AddGroup] Group [$($Group.Name)] is already loaded"))
        }
    }

    [void]AddPermission([Permission]$Permission) {
        if (-not $this.Permissions.ContainsKey($Permission.ToString())) {
            $this._Logger.Info([LogMessage]::new("[RoleManager:AddPermission] Adding permission [$($Permission.Name)]"))
            $this.Permissions.Add($Permission.ToString(), $Permission)
            $this.SaveState()
        } else {
            $this._Logger.Info([LogMessage]::new("[RoleManager:AddPermission] Permission [$($Permission.Name)] is already loaded"))
        }
    }

    [void]AddRole([Role]$Role) {
        if (-not $this.Roles.ContainsKey($Role.Name)) {
            $this._Logger.Info([LogMessage]::new("[RoleManager:AddRole] Adding role [$($Role.Name)]"))
            $this.Roles.Add($Role.Name, $Role)
            $this.SaveState()
        } else {
            $this._Logger.Info([LogMessage]::new("[RoleManager:AddRole] Role [$($Role.Name)] is already loaded"))
        }
    }

    [void]RemoveGroup([Group]$Group) {
        if ($this.Groups.ContainsKey($Group.Name)) {
            $this._Logger.Info([LogMessage]::new("[RoleManager:RemoveGroup] Removing group [$($Group.Name)]"))
            $this.Groups.Remove($Group.Name)
            $this.SaveState()
        } else {
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemoveGroup] Group [$($Group.Name)] was not found"))
        }
    }

    [void]RemovePermission([Permission]$Permission) {
        if (-not $this.Permissions.ContainsKey($Permission.ToString())) {

            # Remove the permission from roles
            foreach ($role in $this.Roles.GetEnumerator()) {
                if ($role.Value.Permissions.ContainsKey($Permission.ToString())) {
                    $this._Logger.Info([LogMessage]::new("[RoleManager:RemovePermission] Removing permission [$($Permission.ToString())] from role [$($role.Value.Name)]"))
                    $role.Value.RemovePermission($Permission)
                }
            }

            $this._Logger.Info([LogMessage]::new("[RoleManager:RemoveGroup] Removing permission [$($Permission.ToString())]"))
            $this.Permissions.Remove($Permission.ToString())
            $this.SaveState()
        } else {
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemovePermission] Permission [$($Permission.ToString())] was not found"))
        }
    }

    [void]RemoveRole([Role]$Role) {
        if ($this.Roles.ContainsKey($Role.Name)) {

            # Remove the role from groups
            foreach ($group in $this.Groups.GetEnumerator()) {
                if ($group.Value.Roles.ContainsKey($Role.Name)) {
                    $this._Logger.Info([LogMessage]::new("[RoleManager:RemoveRole] Removing role [$($Role.Name)] from group [$($group.Value.Name)]"))
                    $group.Value.RemoveRole($Role)
                }
            }

            $this._Logger.Info([LogMessage]::new("[RoleManager:RemoveRole] Removing role [$($Role.Name)]"))
            $this.Roles.Remove($Role.Name)
            $this.SaveState()
        } else {
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemoveRole] Role [$($Role.Name)] was not found"))
        }
    }

    [void]AddRoleToGroup([string]$RoleName, [string]$GroupName) {
        try {
            if ($role = $this.GetRole($RoleName)) {
                if ($group = $this.Groups[$GroupName]) {
                    $msg = "Adding role [$RoleName] to group [$($group.Name)]"
                    $this._Logger.Info([LogMessage]::new("[RoleManager:AddRoleToGroup] $msg"))
                    $group.AddRole($role)
                    $this.SaveState()
                } else {
                    $msg = "Unknown group [$GroupName]"
                    $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:AddRoleToGroup] $msg"))
                    throw $msg
                }
            } else {
                $msg = "Unable to find role [$RoleName]"
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:AddRoleToGroup] $msg"))
                throw $msg
            }
        } catch {
            throw $_
        }
    }

    [void]AddUserToGroup([string]$UserId, [string]$GroupName) {
        try {
            if ($this._Backend.GetUser($UserId)) {
                if ($group = $this.Groups[$GroupName]) {
                    $msg = "Adding user [$UserId] to [$($group.Name)]"
                    $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:AddUserToGroup] $msg"))
                    $group.AddUser($UserId)
                    $this.SaveState()
                } else {
                    $msg = "Unknown group [$GroupName]"
                    $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:AddUserToGroup] $msg"))
                    throw $msg
                }
            } else {
                $msg = "Unable to find user [$UserId]"
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:AddUserToGroup] $msg"))
                throw $msg
            }
        } catch {
            throw $_
        }
    }

    [void]RemoveRoleFromGroup([string]$RoleName, [string]$GroupName) {
        try {
            if ($role = $this.GetRole($RoleName)) {
                if ($group = $this.Groups[$GroupName]) {
                    $msg = "Removing role [$RoleName] from group [$($group.Name)]"
                    $this._Logger.Info([LogMessage]::new("[RoleManager:RemoveUserFromGroup] $msg"))
                    $group.RemoveRole($role)
                    $this.SaveState()
                } else {
                    $msg = "Unknown group [$GroupName]"
                    $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemoveUserFromGroup] $msg"))
                    throw $msg
                }
            } else {
                $msg = "Unable to find role [$RoleName]"
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemoveUserFromGroup] $msg"))
                throw $msg
            }
        } catch {
            throw $_
        }
    }

    [void]RemoveUserFromGroup([string]$UserId, [string]$GroupName) {
        try {
            if ($group = $this.Groups[$GroupName]) {
                if ($group.Users.ContainsKey($UserId)) {
                    $group.RemoveUser($UserId)
                    $this.SaveState()
                }
            } else {
                $msg = "Unknown group [$GroupName]"
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemoveUserFromGroup] $msg"))
                throw $msg
            }
        } catch {
            throw $_
        }
    }

    [void]AddPermissionToRole([string]$PermissionName, [string]$RoleName) {
        try {
            if ($role = $this.GetRole($RoleName)) {
                if ($perm = $this.Permissions[$PermissionName]) {
                    $msg = "Adding permission [$PermissionName] to role [$($role.Name)]"
                    $this._Logger.Info([LogMessage]::new("[RoleManager:AddPermissionToRole] $msg"))
                    $role.AddPermission($perm)
                    $this.SaveState()
                } else {
                    $msg = "Unknown permission [$perm]"
                    $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:AddPermissionToRole] $msg"))
                    throw $msg
                }
            } else {
                $msg = "Unable to find role [$RoleName]"
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:AddPermissionToRole] $msg"))
                throw $msg
            }
        } catch {
            throw $_
        }
    }

    [void]RemovePermissionFromRole([string]$PermissionName, [string]$RoleName) {
        try {
            if ($role = $this.GetRole($RoleName)) {
                if ($perm = $this.Permissions[$PermissionName]) {
                    $msg = "Removing permission [$PermissionName] from role [$($role.Name)]"
                    $this._Logger.Info([LogMessage]::new("[RoleManager:RemovePermissionFromRole] $msg"))
                    $role.RemovePermission($perm)
                    $this.SaveState()
                } else {
                    $msg = "Unknown permission [$PermissionName]"
                    $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemovePermissionFromRole] $msg"))
                    throw $msg
                }
            } else {
                $msg = "Unable to find role [$RoleName]"
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[RoleManager:RemovePermissionFromRole] $msg"))
                throw $msg
            }
        } catch {
            throw $_
        }
    }

    [Group[]]GetUserGroups([string]$UserId) {
        $userGroups = New-Object System.Collections.ArrayList

        foreach ($group in $this.Groups.GetEnumerator()) {
            if ($group.Value.Users.ContainsKey($UserId)) {
                $userGroups.Add($group.Value)
            }
        }
        return $userGroups
    }

    [Role[]]GetUserRoles([string]$UserId) {
        $userRoles = New-Object System.Collections.ArrayList

        foreach ($group in $this.GetUserGroups($UserId)) {
            foreach ($role in $group.Roles.GetEnumerator()) {
                $userRoles.Add($role.Value)
            }
        }

        return $userRoles
    }

    [Permission[]]GetUserPermissions([string]$UserId) {
        $userPermissions = New-Object System.Collections.ArrayList

        if ($userRoles = $this.GetUserRoles($UserId)) {
            foreach ($role in $userRoles) {
                $userPermissions.AddRange($role.Permissions.Keys)
            }
        }

        return $userPermissions
    }

    # Resolve a user to their Id
    # This may be passed either a user name or Id
    [string]ResolveUserToId([string]$Username) {
        $id = $this._Backend.UsernameToUserId($Username)
        if ($id) {
            return $id
        } else {
            $name = $this._Backend.UserIdToUsername($Username)
            if ($name) {
                # We already have a valid user ID since we were able to resolve it to a username.
                # Just return what was passed in
                $id = $name
            }
        }
        $this._Logger.Verbose([LogMessage]::new("[RoleManager:ResolveUserToId] Resolved [$Username] to [$id]"))
        return $id
    }
}

# Some custom exceptions dealing with executing commands
class CommandException : Exception {
    CommandException() {}
    CommandException([string]$Message) : base($Message) {}
}
class CommandNotFoundException : CommandException {
    CommandNotFoundException() {}
    CommandNotFoundException([string]$Message) : base($Message) {}
}
class CommandFailed : CommandException {
    CommandFailed() {}
    CommandFailed([string]$Message) : base($Message) {}
}
class CommandDisabled : CommandException {
    CommandDisabled() {}
    CommandDisabled([string]$Message) : base($Message) {}
}
class CommandNotAuthorized : CommandException {
    CommandNotAuthorized() {}
    CommandNotAuthorized([string]$Message) : base($Message) {}
}
class CommandRequirementsNotMet : CommandException {
    CommandRequirementsNotMet() {}
    CommandRequirementsNotMet([string]$Message) : base($Message) {}
}

# Represent a command that can be executed
class Command {

    # Unique (to the plugin) name of the command
    [string]$Name

    #[hashtable]$Subcommands = @{}

    [string]$Description

    [Trigger]$Trigger

    [string[]]$Usage

    [bool]$KeepHistory = $true

    [bool]$HideFromHelp = $false

    [bool]$AsJob = $true

    # Fully qualified name of a cmdlet or function in a module to execute
    [string]$ModuleCommand

    [string]$ManifestPath

    [System.Management.Automation.FunctionInfo]$FunctionInfo

    [AccessFilter]$AccessFilter = [AccessFilter]::new()

    [bool]$Enabled = $true

    # Execute the command in a PowerShell job and return the running job
    [object]Invoke([ParsedCommand]$ParsedCommand, [bool]$InvokeAsJob = $this.AsJob) {

        $outer = {
            [cmdletbinding()]
            param(
                [hashtable]$Options
            )

            Import-Module -Name $Options.ManifestPath -Scope Local -Force -Verbose:$false -WarningAction SilentlyContinue

            $named = $Options.NamedParameters
            $pos = $Options.PositionalParameters
            $func = $Options.Function

            & $func @named @pos
        }

        [string]$sb = [string]::Empty
        $options = @{
            NamedParameters = $ParsedCommand.NamedParameters
            PositionalParameters = $ParsedCommand.PositionalParameters
            ManifestPath = $this.ManifestPath
            Function = $this.FunctionInfo
        }
        if ($this.FunctionInfo) {
            $options.FunctionInfo = $this.FunctionInfo
        }

        if ($InvokeAsJob) {
            $fdt = Get-Date -Format FileDateTimeUniversal
            $jobName = "$($this.Name)_$fdt"
            $jobParams = @{
                Name = $jobName
                ScriptBlock = $outer
                ArgumentList = $options
            }
            return (Start-Job @jobParams)
        } else {
            $errors = $null
            $information = $null
            $warning = $null
            New-Variable -Name opts -Value $options
            $cmdParams = @{
                ScriptBlock = $outer
                ArgumentList = $Options
                ErrorVariable = 'errors'
                InformationVariable = 'information'
                WarningVariable = 'warning'
                Verbose = $true
                NoNewScope = $true
            }
            $output = Invoke-Command @cmdParams
            return @{
                Error = @($errors)
                Information = @($Information)
                Output = $output
                Warning = @($warning)
            }
        }
    }

    [bool]IsAuthorized([string]$UserId, [RoleManager]$RoleManager) {
        $perms = $RoleManager.GetUserPermissions($UserId)
        foreach ($perm in $perms) {
            $result = $this.AccessFilter.Authorize($perm.Name)
            if ($result.Authorized) {
                return $true
            }
        }

        return $false
    }

    [void]Activate() {
        $this.Enabled = $true
    }

    [void]Deactivate() {
        $this.Enabled = $false
    }

    # [void]AddSubCommand([Command]$Command) {
    # $subCommandName = $null
    # if ($Command.Name.Contains('-')) {
    # $subCommandName = $Command.Name.Split('-')[0]
    # } elseIf ($Command.Name.Contains('_')) {
    # $subCommandName = $Command.Name.Split('_')[0]
    # }
    # if ($subCommandName) {
    # if (-not $this.Subcommands.ContainsKey($subCommandName)) {
    # $this.Subcommands.Add($subCommandName, $Command)
    # }
    # }
    # }

    [void]AddPermission([Permission]$Permission) {
        $this.AccessFilter.AddPermission($Permission)
    }

    [void]RemovePermission([Permission]$Permission) {
        $this.AccessFilter.RemovePermission($Permission)
    }

    # Returns TRUE/FALSE if this command matches a parsed command from the chat network
    [bool]TriggerMatch([ParsedCommand]$ParsedCommand, [bool]$CommandSearch = $true) {
        switch ($this.Trigger.Type) {
            'Command' {
                if ($CommandSearch) {
                    # Command tiggers only work with normal messages received from chat network
                    if ($ParsedCommand.OriginalMessage.Type -eq [MessageType]::Message) {
                        if ($this.Trigger.Trigger -eq $ParsedCommand.Command) {
                                return $true
                            } else {
                                return $false
                        }
                    } else {
                        return $false
                    }
                } else {
                    return $false
                }
            }
            'Event' {
                if ($this.Trigger.MessageType -eq $ParsedCommand.OriginalMessage.Type) {
                    if ($this.Trigger.MessageSubtype -eq $ParsedCommand.OriginalMessage.Subtype) {
                        return $true
                    }
                } else {
                    return $false
                }
            }
            'Regex' {
                if ($ParsedCommand.CommandString -match $this.Trigger.Trigger) {
                    return $true
                } else {
                    return $false
                }
            }
        }
        return $false
    }
}

class CommandHistory {
    [string]$Id

    # ID of command
    [string]$CommandId

    # ID of caller
    [string]$CallerId

    # Command results
    [CommandResult]$Result

    [ParsedCommand]$ParsedCommand

    # Date/time command was executed
    [datetime]$Time

    CommandHistory([string]$CommandId, [string]$CallerId, [CommandResult]$Result, [ParsedCommand]$ParsedCommand) {
        $this.Id = (New-Guid).ToString() -Replace '-', ''
        $this.CommandId = $CommandId
        $this.CallerId = $CallerId
        $this.Result = $Result
        $this.ParsedCommand = $ParsedCommand
        $this.Time = Get-Date
    }
}
# The Plugin class holds a collection of related commands that came
# from a PowerShell module

# Some custom exceptions dealing with plugins
class PluginException : Exception {
    PluginException() {}
    PluginException([string]$Message) : base($Message) {}
}

class PluginNotFoundException : PluginException {
    PluginNotFoundException() {}
    PluginNotFoundException([string]$Message) : base($Message) {}
}

class PluginDisabled : PluginException {
    PluginDisabled() {}
    PluginDisabled([string]$Message) : base($Message) {}
}

# Represents a fully qualified module command
class ModuleCommand {
    [string]$Module
    [string]$Command

    [string]ToString() {
        return "$($this.Module)\$($this.Command)"
    }
}

class Plugin {

    # Unique name for the plugin
    [string]$Name

    # Commands bundled with plugin
    [hashtable]$Commands = @{}

    [version]$Version

    [bool]$Enabled

    [hashtable]$Permissions = @{}

    hidden [string]$_ManifestPath

    Plugin() {
        $this.Name = $this.GetType().Name
        $this.Enabled = $true
    }

    Plugin([string]$Name) {
        $this.Name = $Name
        $this.Enabled = $true
    }

    # Find the command
    [Command]FindCommand([Command]$Command) {
        return $this.Commands.($Command.Name)
    }

    # Add a PowerShell module to the plugin
    [void]AddModule([string]$ModuleName) {
        if (-not $this.Modules.ContainsKey($ModuleName)) {
            $this.Modules.Add($ModuleName, $null)
            $this.LoadModuleCommands($ModuleName)
        }
    }

    # Add a new command
    [void]AddCommand([Command]$Command) {
        if (-not $this.FindCommand($Command)) {
            $this.Commands.Add($Command.Name, $Command)
        }
    }

    # Remove an existing command
    [void]RemoveCommand([Command]$Command) {
        $existingCommand = $this.FindCommand($Command)
        if ($existingCommand) {
            $this.Commands.Remove($Command.Name)
        }
    }

    # Activate a command
    [void]ActivateCommand([Command]$Command) {
        $existingCommand = $this.FindCommand($Command)
        if ($existingCommand) {
            $existingCommand.Activate()
        }
    }

    # Deactivate a command
    [void]DeactivateCommand([Command]$Command) {
        $existingCommand = $this.FindCommand($Command)
        if ($existingCommand) {
            $existingCommand.Deactivate()
        }
    }

    [void]AddPermission([Permission]$Permission) {
        if (-not $this.Permissions.ContainsKey($Permission.ToString())) {
            $this.Permissions.Add($Permission.ToString(), $Permission)
        }
    }

    [Permission]GetPermission([string]$Name) {
        return $this.Permissions[$Name]
    }

    [void]RemovePermission([Permission]$Permission) {
        if ($this.Permissions.ContainsKey($Permission.ToString())) {
            $this.Permissions.Remove($Permission.ToString())
        }
    }

    # Activate plugin and all commands
    [void]Activate() {
        $this.Enabled = $true
        $this.Commands.GetEnumerator() | ForEach-Object {
            $_.Value.Activate()
        }
    }

    # Deactivate plugin and all commands
    [void]Deactivate() {
        $this.Enabled = $false
        $this.Commands.GetEnumerator() | ForEach-Object {
            $_.Value.Deactivate()
        }
    }

    [hashtable]ToHash() {
        $cmdPerms = @{}
        $this.Commands.GetEnumerator() | Foreach-Object {
            $cmdPerms.Add($_.Name, $_.Value.AccessFilter.Permissions.Keys)
        }

        $adhocPerms = New-Object System.Collections.ArrayList
        $this.Permissions.GetEnumerator() | Where-Object {$_.Value.Adhoc -eq $true} | Foreach-Object {
            $adhocPerms.Add($_.Name) > $null
        }
        return @{
            Name = $this.Name
            Version = $this.Version.ToString()
            Enabled = $this.Enabled
            ManifestPath = $this._ManifestPath
            CommandPermissions = $cmdPerms
            AdhocPermissions = $adhocPerms
        }
    }
}

class PluginCommand {
    [Plugin]$Plugin
    [Command]$Command

    PluginCommand([Plugin]$Plugin, [Command]$Command) {
        $this.Plugin = $Plugin
        $this.Command = $Command
    }

    [string]ToString() {
        return "$($this.Plugin.Name):$($this.Command.Name)"
    }
}

# In charge of executing and tracking progress of commands
class CommandExecutor {

    [RoleManager]$RoleManager

    [int]$HistoryToKeep = 100

    [int]$ExecutedCount = 0

    # Recent history of commands executed
    [System.Collections.ArrayList]$History = (New-Object System.Collections.ArrayList)

    # Plugin commands get executed as PowerShell jobs
    # This is to keep track of those
    hidden [hashtable]$_JobTracker = @{}

    CommandExecutor([RoleManager]$RoleManager) {
        $this.RoleManager = $RoleManager
    }

    # Invoke a command
    # Should this live in the Plugin or in the main bot class?
    [CommandResult]ExecuteCommand([PluginCommand]$PluginCmd, [ParsedCommand]$ParsedCommand, [String]$UserId) {

        $command = $pluginCmd.Command

        # Our result
        $r = [CommandResult]::New()

        # Verify command is not disabled
        if (-not $Command.Enabled) {
            $err = [CommandDisabled]::New("Command [$($Command.Name)] is disabled")
            $r.Success = $false
            $r.Errors += $err
            Write-Error -Exception $err
            return $r
        }

        # Verify that all mandatory parameters have been provided for "command" type bot commands
        # This doesn't apply to commands triggered from regex matches, timers, or events
        if ($command.Trigger.Type -eq [TriggerType]::Command) {
            if (-not $this.ValidateMandatoryParameters($ParsedCommand, $command)) {
                $msg = "Mandatory parameters for [$($Command.Name)] not provided.`nUsage:`n"
                foreach ($usage in $Command.Usage) {
                    $msg += " $usage`n"
                }
                $err = [CommandRequirementsNotMet]::New($msg)
                $r.Success = $false
                $r.Errors += $err
                Write-Error -Exception $err
                return $r
            }
        }

        # If command is [command] type verify that the caller is authorized to execute command
        if ($command.Trigger.Type -eq [TriggerType]::Command) {
            $authorized = $command.IsAuthorized($UserId, $this.RoleManager)
        } else {
            $authorized = $true
        }

        if ($authorized) {
            $jobDuration = Measure-Command -Expression {
                if ($existingCommand.AsJob) {
                    $job = $command.Invoke($ParsedCommand, $true)

                    # TODO
                    # Tracking the job will be used later so we can continue on
                    # without having to wait for the job to complete
                    #$this.TrackJob($job)

                    $job | Wait-Job

                    # Capture all the streams
                    $r.Streams.Error = $job.ChildJobs[0].Error.ReadAll()
                    $r.Streams.Information = $job.ChildJobs[0].Information.ReadAll()
                    $r.Streams.Verbose = $job.ChildJobs[0].Verbose.ReadAll()
                    $r.Streams.Warning = $job.ChildJobs[0].Warning.ReadAll()
                    $r.Output = $job.ChildJobs[0].Output.ReadAll()

                    Write-Verbose -Message "Command results: `n$($r | ConvertTo-Json)"

                    # Determine if job had any terminating errors
                    if ($job.State -eq 'Failed' -or $r.Streams.Error.Count -gt 0) {
                        $r.Success = $false
                    } else {
                        $r.Success = $true
                    }
                } else {
                    try {
                        $hash = $command.Invoke($ParsedCommand, $false)
                        $r.Errors = $hash.Error
                        $r.Streams.Error = $hash.Error
                        $r.Streams.Information = $hash.Information
                        $r.Streams.Warning = $hash.Warning
                        $r.Output = $hash.Output
                        if ($r.Errors.Count -gt 0) {
                            $r.Success = $false
                        } else {
                            $r.Success = $true
                        }
                    } catch {
                        $r.Success = $false
                        $r.Errors = $_.Exception.Message
                        $r.Streams.Error = $_.Exception.Message
                    }
                }
            }
            $r.Duration = $jobDuration

            # Add command result to history
            if ($command.KeepHistory) {
                $this.AddToHistory($PluginCmd.ToString(), $UserId, $r, $ParsedCommand)
            }
        } else {
            $r.Success = $false
            $r.Authorized = $false
            $r.Errors += [CommandNotAuthorized]::New("Command [$($Command.Name)] was not authorized for user [$($UserId)]")
        }

        # Track number of commands executed
        if ($r.Success) {
            $this.ExecutedCount++
        }
        return $r
    }

    [void]TrackJob($job) {
        if (-not $this._JobTracker.ContainsKey($job.Name)) {
            $this._JobTracker.Add($job.Name, $job)
        }
    }

    # Add command result to history
    [void]AddToHistory([string]$CommandName, [string]$UserId, [CommandResult]$Result, [ParsedCommand]$ParsedCommand) {
        #$this.History += [CommandHistory]::New($CommandName, $UserId, $Result, $ParsedCommand)
        if ($this.History.Count -ge $this.HistoryToKeep) {
            $this.History.RemoveAt(0) > $null
        }
        $this.History.Add([CommandHistory]::New($CommandName, $UserId, $Result, $ParsedCommand))
    }

    # Validate that all mandatory parameters have been provided
    [bool]ValidateMandatoryParameters([ParsedCommand]$ParsedCommand, [Command]$Command) {
        $functionInfo = $Command.FunctionInfo

        $validated = $false
        foreach ($parameterSet in $functionInfo.ParameterSets) {
            Write-Verbose -Message "[CommandExecutor:ValidateMandatoryParameters] Validating parameters for parameter set [$($parameterSet.Name)]"
            $mandatoryParameters = @($parameterSet.Parameters | Where-Object {$_.IsMandatory -eq $true}).Name
            if ($mandatoryParameters.Count -gt 0) {
                # Remove each provided mandatory parameter from the list
                # so we can find any that will have to be coverd by positional parameters

                Write-Verbose -Message "Provided named parameters: $($ParsedCommand.NamedParameters.Keys | Format-List | Out-String)"
                foreach ($providedNamedParameter in $ParsedCommand.NamedParameters.Keys ) {
                    Write-Verbose -Message "Named parameter [$providedNamedParameter] provided"
                    $mandatoryParameters = @($mandatoryParameters | Where-Object {$_ -ne $providedNamedParameter})
                }
                if ($mandatoryParameters.Count -gt 0) {
                    if ($ParsedCommand.PositionalParameters.Count -lt $mandatoryParameters.Count) {
                        $validated = $false
                    } else {
                        $validated = $true
                    }
                } else {
                    $validated = $true
                }
            } else {
                $validated = $true
            }

            Write-Verbose -Message "[CommandExecutor:ValidateMandatoryParameters] Valid parameters for parameterset [$($parameterSet.Name)] [$($validated.ToString())]"
            if ($validated) {
                break
            }
        }

        return $validated
    }
}

class ConfigProvidedParameter {
    [PoshBot.FromConfig]$Metadata
    [System.Management.Automation.ParameterMetadata]$Parameter

    ConfigProvidedParameter([PoshBot.FromConfig]$Meta, [System.Management.Automation.ParameterMetadata]$Param) {
        $this.Metadata = $Meta
        $this.Parameter = $param
    }
}

class PluginManager {

    [hashtable]$Plugins = @{}
    [hashtable]$Commands = @{}
    hidden [string]$_PoshBotModuleDir
    [RoleManager]$RoleManager
    [StorageProvider]$_Storage
    [Logger]$Logger

    PluginManager([RoleManager]$RoleManager, [StorageProvider]$Storage, [Logger]$Logger, [string]$PoshBotModuleDir) {
        $this.RoleManager = $RoleManager
        $this._Storage = $Storage
        $this.Logger = $Logger
        $this._PoshBotModuleDir = $PoshBotModuleDir
        $this.Initialize()
    }

    # Initialize the plugin manager
    [void]Initialize() {
        $this.Logger.Info([LogMessage]::new('[PluginManager:Initialize] Initializing'))
        $this.LoadState()
        $this.LoadBuiltinPlugins()
    }

    # Get the list of plugins to load and... wait for it... load them
    [void]LoadState() {
        $this.Logger.Verbose([LogMessage]::new('[PluginManager:LoadState] Loading plugin state from storage'))

        $pluginsToLoad = $this._Storage.GetConfig('plugins')
        if ($pluginsToLoad) {
            foreach ($pluginKey in $pluginsToLoad.Keys) {
                $pluginToLoad = $pluginsToLoad[$pluginKey]

                $pluginVersions = $pluginToLoad.Keys
                foreach ($pluginVersionKey in $pluginVersions) {
                    $pv = $pluginToLoad[$pluginVersionKey]
                    $manifestPath = $pv.ManifestPath
                    $adhocPermissions = $pv.AdhocPermissions
                    $this.CreatePluginFromModuleManifest($pluginKey, $manifestPath, $true, $false)

                    if ($newPlugin = $this.Plugins[$pluginKey]) {
                        # Add adhoc permissions back to plugin (all versions)
                        foreach ($version in $newPlugin.Keys) {
                            $npv = $newPlugin[$version]
                            foreach($permission in $adhocPermissions) {
                                if ($p = $this.RoleManager.GetPermission($permission)) {
                                    $npv.AddPermission($p)
                                }
                            }

                            # Add adhoc permissions back to the plugin commands (all versions)
                            $commandPermissions = $pv.CommandPermissions
                            foreach ($commandName in $commandPermissions.Keys ) {
                                $permissions = $commandPermissions[$commandName]
                                foreach ($permission in $permissions) {
                                    if ($p = $this.RoleManager.GetPermission($permission)) {
                                        $npv.AddPermission($p)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    # Save the state of currently loaded plugins to storage
    [void]SaveState() {
        $this.Logger.Verbose([LogMessage]::new('[PluginManager:SaveState] Saving loaded plugin state to storage'))

        # Skip saving builtin plugin as it will always be loaded at initialization
        $pluginsToSave = @{}
        foreach($pluginKey in $this.Plugins.Keys | Where-Object {$_ -ne 'Builtin'}) {
            $versions = @{}
            foreach ($versionKey in $this.Plugins[$pluginKey].Keys) {
                $pv = $this.Plugins[$pluginKey][$versionKey]
                $versions.Add($versionKey, $pv.ToHash())
            }
            $pluginsToSave.Add($pluginKey, $versions)
        }
        $this._Storage.SaveConfig('plugins', $pluginsToSave)
    }

    # TODO
    # Given a PowerShell module definition, inspect it for commands etc,
    # create a plugin instance and load the plugin
    [void]InstallPlugin([string]$ManifestPath, [bool]$SaveAfterInstall = $false) {
        if (Test-Path -Path $ManifestPath) {
            $moduleName = (Get-Item -Path $ManifestPath).BaseName
            $this.CreatePluginFromModuleManifest($moduleName, $ManifestPath, $true, $SaveAfterInstall)
        } else {
            Write-Error -Message "Module manifest path [$manifestPath] not found"
        }
    }

    # Add a plugin to the bot
    [void]AddPlugin([Plugin]$Plugin, [bool]$SaveAfterInstall = $false) {
        if (-not $this.Plugins.ContainsKey($Plugin.Name)) {
            $this.Logger.Info([LogMessage]::new("[PluginManager:AddPlugin] Attaching plugin [$($Plugin.Name)]"))

            $pluginVersion = @{
                ($Plugin.Version).ToString() = $Plugin
            }
            $this.Plugins.Add($Plugin.Name, $pluginVersion)

            # Register the plugins permission set with the role manager
            foreach ($permission in $Plugin.Permissions.GetEnumerator()) {
                $this.Logger.Info([LogMessage]::new("[PluginManager:AddPlugin] Adding permission [$($permission.Value.ToString())] to Role Manager"))
                $this.RoleManager.AddPermission($permission.Value)
            }
        } else {

            if (-not $this.Plugins[$Plugin.Name].ContainsKey($Plugin.Version)) {
                # Install a new plugin version
                $this.Logger.Info([LogMessage]::new("[PluginManager:AddPlugin] Attaching version [$($Plugin.Version)] of plugin [$($Plugin.Name)]"))

                $this.Plugins[$Plugin.Name].Add($Plugin.Version.ToString(), $Plugin)

                # Register the plugins permission set with the role manager
                foreach ($permission in $Plugin.Permissions.GetEnumerator()) {
                    $this.Logger.Info([LogMessage]::new("[PluginManager:AddPlugin] Adding permission [$($permission.Value.ToString())] to Role Manager"))
                    $this.RoleManager.AddPermission($permission.Value)
                }
            } else {
                throw [PluginException]::New("Plugin [$($Plugin.Name)] version [$($Plugin.Version)] is already loaded")
            }
        }

        # # Reload commands and role from all currently loading (and active) plugins
        $this.LoadCommands()

        if ($SaveAfterInstall) {
            $this.SaveState()
        }
    }

    # Remove a plugin from the bot
    [void]RemovePlugin([Plugin]$Plugin) {
        if ($this.Plugins.ContainsKey($Plugin.Name)) {
            $pluginVersions = $this.Plugins[$Plugin.Name]
            if ($pluginVersions.Keys.Count -eq 1) {
                # Remove the permissions for this plugin from the role manaager
                # but only if this is the only version of the plugin loaded
                foreach ($permission in $Plugin.Permissions.GetEnumerator()) {
                    $this.Logger.Verbose([LogMessage]::new("[PluginManager:RemovePlugin] Removing permission [$($Permission.Value.ToString())]. No longer in use"))
                    $this.RoleManager.RemovePermission($Permission.Value)
                }
                $this.Logger.Info([LogMessage]::new("[PluginManager:RemovePlugin] Removing plugin [$($Plugin.Name)]"))
                $this.Plugins.Remove($Plugin.Name)
            } else {
                if ($pluginVersions.ContainsKey($Plugin.Version)) {
                    $this.Logger.Info([LogMessage]::new("[PluginManager:RemovePlugin] Removing plugin [$($Plugin.Name)] version [$($Plugin.Version)]"))
                    $pluginVersions.Remove($Plugin.Version)
                } else {
                    throw [PluginNotFoundException]::New("Plugin [$($Plugin.Name)] version [$($Plugin.Version)] is not loaded in bot")
                }
            }
        }

        # Reload commands from all currently loading (and active) plugins
        $this.LoadCommands()

        $this.SaveState()
    }

    # Remove a plugin and optionally a specific version from the bot
    # If there is only one version, then remove any permissions defined in the plugin as well
    [void]RemovePlugin([string]$PluginName, [string]$Version) {
        if ($p = $this.Plugins[$PluginName]) {
            if ($pv = $p[$Version]) {

                if ($p.Keys.Count -eq 1) {
                    # Remove the permissions for this plugin from the role manaager
                    # but only if this is the only version of the plugin loaded
                    foreach ($permission in $pv.Permissions.GetEnumerator()) {
                        $this.Logger.Verbose([LogMessage]::new("[PluginManager:RemovePlugin] Removing permission [$($Permission.Value.ToString())]. No longer in use"))
                        $this.RoleManager.RemovePermission($Permission.Value)
                    }
                    $this.Logger.Info([LogMessage]::new("[PluginManager:RemovePlugin] Removing plugin [$($pv.Name)]"))
                    $this.Plugins.Remove($pv.Name)
                } else {
                    $this.Logger.Info([LogMessage]::new("[PluginManager:RemovePlugin] Removing plugin [$($pv.Name)] version [$Version]"))
                    $p.Remove($pv.Version.ToString())
                }
            } else {
                throw [PluginNotFoundException]::New("Plugin [$PluginName] version [$Version] is not loaded in bot")
            }
        } else {
            throw [PluginNotFoundException]::New("Plugin [$PluginName] is not loaded in bot")
        }

        # Reload commands from all currently loading (and active) plugins
        $this.LoadCommands()

        $this.SaveState()
    }

    # Activate a plugin
    [void]ActivatePlugin([string]$PluginName, [string]$Version) {
        if ($p = $this.Plugins[$PluginName]) {
            if ($pv = $p[$Version]) {
                $this.Logger.Info([LogMessage]::new("[PluginManager:ActivatePlugin] Activating plugin [$PluginName] version [$Version]"))
                $pv.Activate()

                # Reload commands from all currently loading (and active) plugins
                $this.LoadCommands()
                $this.SaveState()
            } else {
                throw [PluginNotFoundException]::New("Plugin [$PluginName] version [$Version] is not loaded in bot")
            }
        } else {
            throw [PluginNotFoundException]::New("Plugin [$PluginName] is not loaded in bot")
        }
    }

    # Activate a plugin
    [void]ActivatePlugin([Plugin]$Plugin) {
        $p = $this.Plugins[$Plugin.Name]
        if ($p) {
            if ($pv = $p[$Plugin.Version.ToString()]) {
                $this.Logger.Info([LogMessage]::new("[PluginManager:ActivatePlugin] Activating plugin [$($Plugin.Name)] version [$($Plugin.Version)]"))
                $pv.Activate()
            }
        } else {
            throw [PluginNotFoundException]::New("Plugin [$($Plugin.Name)] version [$($Plugin.Version)] is not loaded in bot")
        }

        # Reload commands from all currently loading (and active) plugins
        $this.LoadCommands()

        $this.SaveState()
    }

    # Deactivate a plugin
    [void]DeactivatePlugin([Plugin]$Plugin) {
        $p = $this.Plugins[$Plugin.Name]
        if ($p) {
            if ($pv = $p[$Plugin.Version.ToString()]) {
                $this.Logger.Info([LogMessage]::new("[PluginManager:DeactivatePlugin] Deactivating plugin [$($Plugin.Name)] version [$($Plugin.Version)]"))
                $pv.Deactivate()
            }
        } else {
            throw [PluginNotFoundException]::New("Plugin [$($Plugin.Name)] version [$($Plugin.Version)] is not loaded in bot")
        }

        # # Reload commands from all currently loading (and active) plugins
        $this.LoadCommands()

        $this.SaveState()
    }

    # Deactivate a plugin
    [void]DeactivatePlugin([string]$PluginName, [string]$Version) {
        if ($p = $this.Plugins[$PluginName]) {
            if ($pv = $p[$Version]) {
                $this.Logger.Info([LogMessage]::new("[PluginManager:DeactivatePlugin] Deactivating plugin [$PluginName)] version [$Version]"))
                $pv.Deactivate()

                # Reload commands from all currently loading (and active) plugins
                $this.LoadCommands()
                $this.SaveState()
            } else {
                throw [PluginNotFoundException]::New("Plugin [$PluginName] version [$Version] is not loaded in bot")
            }
        } else {
            throw [PluginNotFoundException]::New("Plugin [$PluginName] is not loaded in bot")
        }
    }

    # Match a parsed command to a command in one of the currently loaded plugins
    [PluginCommand]MatchCommand([ParsedCommand]$ParsedCommand, [bool]$CommandSearch = $true) {

        # Check builtin commands first
        $builtinKey = $this.Plugins['Builtin'].Keys | Select-Object -First 1
        $builtinPlugin = $this.Plugins['Builtin'][$builtinKey]
        foreach ($commandKey in $builtinPlugin.Commands.Keys) {
            $command = $builtinPlugin.Commands[$commandKey]
            if ($command.TriggerMatch($ParsedCommand, $CommandSearch)) {
                $this.Logger.Info([LogMessage]::new("[PluginManagerBot:MatchCommand] Matched parsed command [$($ParsedCommand.Plugin)`:$($ParsedCommand.Command)] to builtin command [Builtin:$commandKey]"))
                return [PluginCommand]::new($builtinPlugin, $command)
            }
        }

        # If parsed command is fully qualified with <plugin:command> syntax. Just look in that plugin
        if (($ParsedCommand.Plugin -ne [string]::Empty) -and ($ParsedCommand.Command -ne [string]::Empty)) {
            $plugin = $this.Plugins[$ParsedCommand.Plugin]
            if ($plugin) {

                # Just look in the latest version of the plugin.
                # This should be improved later to allow specifying a specific version to execute
                $latestVersionKey = $plugin.Keys | Sort-Object -Descending | Select-Object -First 1
                $pluginVersion = $plugin[$latestVersionKey]

                foreach ($commandKey in $pluginVersion.Commands.Keys) {
                    $command = $pluginVersion.Commands[$commandKey]
                    if ($command.TriggerMatch($ParsedCommand, $CommandSearch)) {
                        $this.Logger.Info([LogMessage]::new("[PluginManager:MatchCommand] Matched parsed command [$($ParsedCommand.Plugin)`:$($ParsedCommand.Command)] to plugin command [$($plugin.Name)`:$commandKey]"))
                        return [PluginCommand]::new($pluginVersion, $command)
                    }
                }
                $this.Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[PluginManager:MatchCommand] Unable to match parsed command [$($ParsedCommand.Plugin)`:$($ParsedCommand.Command)] to a command in plugin [$($plugin.Name)]"))
            } else {
                $this.Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[PluginManager:MatchCommand] Unable to match parsed command [$($ParsedCommand.Plugin)`:$($ParsedCommand.Command)] to a plugin command"))
                return $null
            }
        } else {

            # Check all regular plugins/commands now
            foreach ($pluginKey in $this.Plugins.Keys) {
                $plugin = $this.Plugins[$pluginKey]

                # Just look in the latest version of the plugin.
                # This should be improved later to allow specifying a specific version to execute
                foreach ($pluginVersionKey in $plugin.Keys | Sort-Object -Descending | Select-Object -First 1) {
                    $pluginVersion = $plugin[$pluginVersionKey]

                    foreach ($commandKey in $pluginVersion.Commands.Keys) {
                        $command = $pluginVersion.Commands[$commandKey]
                        if ($command.TriggerMatch($ParsedCommand, $CommandSearch)) {
                            $this.Logger.Info([LogMessage]::new("[PluginManager:MatchCommand] Matched parsed command [$($ParsedCommand.Plugin)`:$($ParsedCommand.Command)] to plugin command [$pluginKey`:$commandKey]"))
                            return [PluginCommand]::new($pluginVersion, $command)
                        }
                    }
                }
            }
        }

        $this.Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[PluginManager:MatchCommand] Unable to match parsed command [$($ParsedCommand.Plugin)`:$($ParsedCommand.Command)] to a plugin command"))
        return $null
    }

    # Load in the available commands from all the loaded plugins
    [void]LoadCommands() {
        $allCommands = New-Object System.Collections.ArrayList
        foreach ($pluginKey in $this.Plugins.Keys) {
            $plugin = $this.Plugins[$pluginKey]

            foreach ($pluginVersionKey in $plugin.Keys | Sort-Object -Descending | Select-Object -First 1) {
                $pluginVersion = $plugin[$pluginVersionKey]
                if ($pluginVersion.Enabled) {
                    foreach ($commandKey in $pluginVersion.Commands.Keys) {
                        $command =  $pluginVersion.Commands[$commandKey]
                        $fullyQualifiedCommandName = "$pluginKey`:$CommandKey"
                        $allCommands.Add($fullyQualifiedCommandName)
                        if (-not $this.Commands.ContainsKey($fullyQualifiedCommandName)) {
                            $this.Logger.Verbose([LogMessage]::new("[PluginManager:LoadCommands] Loading command [$fullyQualifiedCommandName]"))
                            $this.Commands.Add($fullyQualifiedCommandName, $command)
                        }
                    }
                }
            }
        }

        # Remove any commands that are not in any of the loaded (and active) plugins
        $remove = New-Object System.Collections.ArrayList
        foreach($c in $this.Commands.Keys) {
            if (-not $allCommands.Contains($c)) {
                $remove.Add($c)
            }
        }
        $remove | ForEach-Object {
            $this.Logger.Verbose([LogMessage]::new("[PluginManager:LoadCommands] Removing command [$_]. Plugin has either been removed or is deactivated."))
            $this.Commands.Remove($_)
        }
    }

    # Create a new plugin from a given module manifest
    [void]CreatePluginFromModuleManifest([string]$ModuleName, [string]$ManifestPath, [bool]$AsJob = $true, [bool]$SaveAfterCreation = $false) {
        $manifest = Import-PowerShellDataFile -Path $ManifestPath -ErrorAction SilentlyContinue
        if ($manifest) {
            $plugin = [Plugin]::new()
            $plugin.Name = $ModuleName
            $plugin._ManifestPath = $ManifestPath
            if ($manifest.ModuleVersion) {
                $plugin.Version = $manifest.ModuleVersion
            } else {
                $plugin.Version = '0.0.0'
            }

            # Load our plugin config
            $pluginConfig = $this.GetPluginConfig($plugin.Name, $plugin.Version)

            # Create new permissions from metadata in the module manifest
            $this.GetPermissionsFromModuleManifest($manifest) | ForEach-Object {
                $_.Plugin = $plugin.Name
                $plugin.AddPermission($_)
            }

            # Add any adhoc permissions that were previously defined back to the plugin
            if ($pluginConfig -and $pluginConfig.AdhocPermissions.Count -gt 0) {
                foreach ($permissionName in $pluginConfig.AdhocPermissions) {
                    if ($p = $this.RoleManager.GetPermission($permissionName)) {
                        $this.Logger.Debug([LogMessage]::new("[PluginManager:CreatePluginFromModuleManifest] Adding adhoc permission [$permissionName] to plugin [$($plugin.Name)]"))
                        $plugin.AddPermission($p)
                    } else {
                        $this.Logger.Info([LogMessage]::new("[PluginManager:CreatePluginFromModuleManifest] Adhoc permission [$permissionName] not found in Role Manager. Can't attach permission to plugin [$($plugin.Name)]"))
                    }
                }
            }

            # Add the plugin so the roles can be registered with the role manager
            $this.AddPlugin($plugin, $SaveAfterCreation)
            $this.Logger.Info([LogMessage]::new("[PluginManager:CreatePluginFromModuleManifest] Created new plugin [$($plugin.Name)]"))

            # Get exported cmdlets/functions from the module and add them to the plugin
            # Adjust bot command behaviour based on metadata as appropriate
            Import-Module -Name $manifestPath -Scope Local -Verbose:$false -WarningAction SilentlyContinue
            $moduleCommands = Microsoft.PowerShell.Core\Get-Command -Module $ModuleName -CommandType @('Cmdlet', 'Function', 'Workflow') -Verbose:$false
            foreach ($command in $moduleCommands) {

                # Get the command help so we can pull information from it
                # to construct the bot command
                $cmdHelp = Get-Help -Name $command.Name

                # Get any command metadata that may be attached to the command
                # via the PoshBot.BotCommand extended attribute
                $metadata = $this.GetCommandMetadata($command)

                $this.Logger.Info([LogMessage]::new("[PluginManager:CreatePluginFromModuleManifest] Creating command [$($command.Name)] for new plugin [$($plugin.Name)]"))
                $cmd = [Command]::new()

                # Set command properties based on metadata from module
                if ($metadata) {
                    # Set the command name / trigger to the module function name or to
                    # what is defined in the metadata
                    if ($metadata.CommandName) {
                        $cmd.Trigger = [Trigger]::new('Command', $metadata.CommandName)
                        $cmd.Name = $metadata.CommandName
                    } else {
                        $cmd.Trigger = [Trigger]::new('Command', $command.Name)
                        $cmd.name = $command.Name
                    }

                    # Add any permissions definede within the plugin to the command
                    if ($metadata.Permissions) {
                        foreach ($item in $metadata.Permissions) {
                            $fqPermission = "$($plugin.Name):$($item)"
                            if ($p = $plugin.GetPermission($fqPermission)) {
                                $cmd.AddPermission($p)
                            } else {
                                Write-Error -Message "Permission [$fqPermission] is not defined in the plugin module manifest. Command will not be added to plugin."
                                continue
                            }
                        }
                    }

                    # Add any adhoc permissions that we may have been added in the past
                    # that is stored in our plugin configuration
                    if ($pluginConfig) {
                        foreach ($permissionName in $pluginConfig.AdhocPermissions) {
                            if ($p = $this.RoleManager.GetPermission($permissionName)) {
                                $this.Logger.Debug([LogMessage]::new("[PluginManager:CreatePluginFromModuleManifest] Adding adhoc permission [$permissionName] to command [$($plugin.Name):$($cmd.name)]"))
                                $cmd.AddPermission($p)
                            } else {
                                $this.Logger.Info([LogMessage]::new("[PluginManager:CreatePluginFromModuleManifest] Adhoc permission [$permissionName] not found in Role Manager. Can't attach permission to command [$($plugin.Name):$($cmd.name)]"))
                            }
                        }
                    }

                    $cmd.KeepHistory = $metadata.KeepHistory    # Default is $true
                    $cmd.HideFromHelp = $metadata.HideFromHelp  # Default is $false

                    # Set the trigger type
                    if ($metadata.TriggerType) {
                        switch ($metadata.TriggerType) {
                            'Comamnd' {
                                $cmd.Trigger.Type = [TriggerType]::Command
                            }
                            'Event' {
                                $cmd.Trigger.Type = [TriggerType]::Event
                            }
                            'Regex' {
                                $cmd.Trigger.Type = [TriggerType]::Regex
                                $cmd.Trigger.Trigger = $metadata.Regex
                            }
                        }
                    } else {
                        $cmd.Trigger.Type = [TriggerType]::Command
                    }

                    # The message type/subtype the command is intended to respond to
                    if ($metadata.MessageType) {
                        $cmd.Trigger.MessageType = $metadata.MessageType
                    }
                    if ($metadata.MessageSubtype) {
                        $cmd.Trigger.MessageSubtype = $metadata.MessageSubtype
                    }
                } else {
                    # No metadata defined so set the command name/trigger to the module function name
                    $cmd.Name = $command.Name
                    $cmd.Trigger = [Trigger]::new('Command', $command.Name)
                }

                $cmd.Description = $cmdHelp.Synopsis.Trim()
                $cmd.ManifestPath = $manifestPath
                $cmd.FunctionInfo = $command

                if ($cmdHelp.examples) {
                    foreach ($example in $cmdHelp.Examples.Example) {
                        $cmd.Usage += $example.code.Trim()
                    }
                }
                $cmd.ModuleCommand = "$ModuleName\$($command.Name)"
                $cmd.AsJob = $AsJob

                $plugin.AddCommand($cmd)
            }

            # If the plugin was previously disabled in our plugin configuration, make sure it still is
            if ($pluginConfig -and (-not $pluginConfig.Enabled)) {
                $plugin.Deactivate()
            }

            $this.LoadCommands()

            if ($SaveAfterCreation) {
                $this.SaveState()
            }
        } else {
            $msg = "Unable to load module manifest [$ManifestPath]"
            $this.Logger.Info([LogMessage]::new([LogSeverity]::Error, $msg))
            Write-Error -Message $msg
        }
    }

    # Get the [Poshbot.BotComamnd()] attribute from the function if it exists
    [PoshBot.BotCommand]GetCommandMetadata([System.Management.Automation.FunctionInfo]$Command) {
        $attrs = $Command.ScriptBlock.Attributes
        $botCmdAttr = $attrs | ForEach-Object {
            if ($_.GetType().ToString() -eq 'PoshBot.BotCommand') {
                $_
            }
        }
        return $botCmdAttr
    }

    # Inspect the module manifest and return any permissions defined
    [Permission[]]GetPermissionsFromModuleManifest($Manifest) {
        $permissions = New-Object System.Collections.ArrayList
        foreach ($permission in $Manifest.PrivateData.Permissions) {
            if ($permission -is [string]) {
                $p = [Permission]::new($Permission)
                $permissions.Add($p)
            } elseIf ($permission -is [hashtable]) {
                $p = [Permission]::new($permission.Name)
                if ($permission.Description) {
                    $p.Description = $permission.Description
                }
                $permissions.Add($p)
            }
        }
        return $permissions
    }

    # Load in the built in plugins
    # These will be marked so that they DON't execute in a PowerShell job
    # as they need access to the bot internals
    [void]LoadBuiltinPlugins() {
        $this.Logger.Info([LogMessage]::new('[PluginManager:LoadBuiltinPlugins] Loading builtin plugins'))
        $builtinPlugin = Get-Item -Path "$($this._PoshBotModuleDir)/Plugins/Builtin"
        $moduleName = $builtinPlugin.BaseName
        $manifestPath = Join-Path -Path $builtinPlugin.FullName -ChildPath "$moduleName.psd1"
        $this.CreatePluginFromModuleManifest($moduleName, $manifestPath, $false, $false)
    }

    [hashtable]GetPluginConfig([string]$PluginName, [string]$Version) {
        if ($pluginConfig = $this._Storage.GetConfig('plugins')) {
            if ($thisPluginConfig = $pluginConfig[$PluginName]) {
                if (-not [string]::IsNullOrEmpty($Version)) {
                    if ($thisPluginConfig.ContainsKey($Version)) {
                        $pluginVersion = $Version
                    } else {
                        return $null
                    }
                } else {
                    $pluginVersion = @($thisPluginConfig.Keys | Sort-Object -Descending)[0]
                }

                $pv = $thisPluginConfig[$pluginVersion]
                return $pv
            } else {
                return $null
            }
        } else {
            return $null
        }
    }
}

# This class holds the bare minimum information necesary to establish a connection to a chat network.
# Specific implementations MAY extend this class to provide more properties
class ConnectionConfig {

    [string]$Endpoint

    [pscredential]$Credential

    ConnectionConfig() {}

    ConnectionConfig([string]$Endpoint, [pscredential]$Credential) {
        $this.Endpoint = $Endpoint
        $this.Credential = $Credential
    }
}

# This class represents the connection to a backend Chat network

class Connection {
    [ConnectionConfig]$Config
    [ConnectionStatus]$Status = [ConnectionStatus]::Disconnected

    [void]Connect() {}

    [void]Disconnect() {}
}

# This generic Backend class provides the base scaffolding to represent a Chat network
class Backend {

    [string]$Name

    [string]$BotId

    # Connection information for the chat network
    [Connection]$Connection

    [hashtable]$Users = @{}

    [hashtable]$Rooms = @{}

    [System.Collections.ArrayList]$IgnoredMessageTypes = (New-Object System.Collections.ArrayList)

    Backend() {}

    # Send a message
    # Must be extended by the specific Backend implementation
    [void]SendMessage([Response]$Response) {}


    # Receive a message
    # Must be extended by the specific Backend implementation
    [Event]ReceiveMessage() {
        $e = [Message]::New()
        return $e
    }

    # Change presence

    #[void]CallbackPresence([Presence]$Presence) {}

    #[void]CallbackRoomJoined([Room]$Room) {}

    #[void]CallbackRoomLeft([Room]$Room) {}

    #[void]CallbackRoomTopic([Room]$Room) {}

    # Connect to the chat network
    [void]Connect() {
        $this.Connection.Connect()
    }

    # Disconnect from the chat network
    [void]Disconnect() {
        $this.Connection.Disconnect()
    }

    [void]LoadUsers() {}

    [void]LoadRooms() {}

    [void]GetBotIdentity() {}

    # Resolve a user name to user id
    [void]UsernameToUserId([string]$Username) {
        # Must be extended by the specific Backend implementation
    }

    # Resolve a user ID to a username/nickname
    [void]UserIdToUsername([string]$UserId) {
        # Must be extended by the specific Backend implementation
    }
}

class BotConfiguration {

    [string]$Name = 'PoshBot'

    [string]$ConfigurationDirectory = (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot')

    [string]$LogDirectory = (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot')

    [string]$PluginDirectory = (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot')

    [string[]]$PluginRepository = @('PSGallery')

    [string[]]$ModuleManifestsToLoad = @()

    [LogLevel]$LogLevel = [LogLevel]::Verbose

    [hashtable]$BackendConfiguration = @{}

    [hashtable]$PluginConfiguration = @{}

    [string[]]$BotAdmins = @()

    [char]$CommandPrefix = '!'

    [string[]]$AlternateCommandPrefixes = @('poshbot')

    [char[]]$AlternateCommandPrefixSeperators = @(':', ',', ';')

    [string[]]$SendCommandResponseToPrivate = @()

    [bool]$MuteUnknownCommand = $false
}

class Bot {

    # Friendly name for the bot
    [string]$Name

    # The backend system for this bot (Slack, HipChat, etc)
    [Backend]$Backend

    hidden [string]$_PoshBotDir

    [StorageProvider]$Storage

    [PluginManager]$PluginManager

    [RoleManager]$RoleManager

    [CommandExecutor]$Executor

    # Queue of messages from the chat network to process
    [System.Collections.Queue]$MessageQueue = (New-Object System.Collections.Queue)

    [BotConfiguration]$Configuration

    hidden [Logger]$_Logger

    hidden [System.Diagnostics.Stopwatch]$_Stopwatch

    hidden [System.Collections.Arraylist] $_PossibleCommandPrefixes = (New-Object System.Collections.ArrayList)

    Bot([Backend]$Backend, [string]$PoshBotDir, [BotConfiguration]$Config) {
        $this.Name = $config.Name
        $this.Backend = $Backend
        $this._PoshBotDir = $PoshBotDir
        $this.Storage = [StorageProvider]::new($Config.ConfigurationDirectory)
        $this.Initialize($Config)
    }

    Bot([string]$Name, [Backend]$Backend, [string]$PoshBotDir, [string]$ConfigPath) {
        $this.Name = $Name
        $this.Backend = $Backend
        $this._PoshBotDir = $PoshBotDir
        $this.Storage = [StorageProvider]::new((Split-Path -Path $ConfigPath -Parent))
        $config = Get-PoshBotConfiguration -Path $ConfigPath
        $this.Initialize($config)
    }

    [void]Initialize([BotConfiguration]$Config) {
        if ($null -eq $Config) {
            $this.LoadConfiguration()
        } else {
            $this.Configuration = $Config
        }
        $this._Logger = [Logger]::new($this.Configuration.LogDirectory, $this.Configuration.LogLevel)
        $this.RoleManager = [RoleManager]::new($this.Backend, $this.Storage, $this._Logger)
        $this.PluginManager = [PluginManager]::new($this.RoleManager, $this.Storage, $this._Logger, $this._PoshBotDir)
        $this.Executor = [CommandExecutor]::new($this.RoleManager)
        $this.GenerateCommandPrefixList()

        # Add internal plugin directory and user-defined plugin directory to PSModulePath
        if (-not [string]::IsNullOrEmpty($this.Configuration.PluginDirectory)) {
            $internalPluginDir = Join-Path -Path $this._PoshBotDir -ChildPath 'Plugins'
            $modulePaths = $env:PSModulePath.Split(';')
            if ($modulePaths -notcontains $internalPluginDir) {
                $env:PSModulePath = $internalPluginDir + ';' + $env:PSModulePath
            }
            if ($modulePaths -notcontains $this.Configuration.PluginDirectory) {
                $env:PSModulePath = $this.Configuration.PluginDirectory + ';' + $env:PSModulePath
            }
        }

        # Set PS repository to trusted
        foreach ($repo in $this.Configuration.PluginRepository) {
            if (Get-PSRepository -Name $repo -Verbose:$false -ErrorAction SilentlyContinue) {
                Set-PSRepository -Name $repo -Verbose:$false -InstallationPolicy Trusted
            } else {
                [LogSeverity]::Error, "[Bot:Initialize] PowerShell repository [$repo)] is not defined"
            }
        }

        # Load in plugins listed in configuration
        if ($this.Configuration.ModuleManifestsToLoad.Count -gt 0) {
            $this._Logger.Info([LogMessage]::new('[Bot:Initialize] Loading in plugins from configuration'))
            foreach ($manifestPath in $this.Configuration.ModuleManifestsToLoad) {
                if (Test-Path -Path $manifestPath) {
                    $this.PluginManager.InstallPlugin($manifestPath, $false)
                } else {
                    $this._Logger.Info(
                        [LogMessage]::new(
                            [LogSeverity]::Warning, "[Bot:Initialize] Could not find manifest at [$manifestPath]"
                        )
                    )
                }
            }
        }
    }

    [void]LoadConfiguration() {
        $botConfig = $this.Storage.GetConfig($this.Name)
        if ($botConfig) {
            $this.Configuration = $botConfig
        } else {
            $this.Configuration = [BotConfiguration]::new()
            $hash = @{}
            $this.Configuration | Get-Member -MemberType Property | ForEach-Object {
                $hash.Add($_.Name, $this.Configuration.($_.Name))
            }
            $this.Storage.SaveConfig('Bot', $hash)
        }
    }

    # Start the bot
    [void]Start() {
        $this._Stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
        $this._Logger.Info([LogMessage]::new('[Bot:Start] Start your engines'))

        try {
            $this.Connect()

            # Start the loop to receive and process messages from the backend
            $sw = [System.Diagnostics.Stopwatch]::StartNew()
            $this._Logger.Info([LogMessage]::new('[Bot:Start] Beginning message processing loop'))
            while ($this.Backend.Connection.Connected) {

                # Receive message and add to queue
                $this.ReceiveMessage()

                # Determine if message is for bot and handle as necessary
                $this.ProcessMessageQueue()

                Start-Sleep -Milliseconds 100

                # Send a ping every 5 seconds
                if ($sw.Elapsed.TotalSeconds -gt 5) {
                    $this.Backend.Ping()
                    $sw.Reset()
                }
            }
        } catch {
            Write-Error $_
            $errJson = [ExceptionFormatter]::ToJson($_)
            $this._Logger.Info([LogMessage]::new([LogSeverity]::Error, "[Bot:Start] Exception [$($_.Exception.Message)]", $errJson))
        } finally {
            $this.Disconnect()
        }
    }

    # Connect the bot to the chat network
    [void]Connect() {
        $this._Logger.Verbose([LogMessage]::new('[Bot:Connect] Connecting to backend chat network'))
        $this.Backend.Connect()

        # That that we're connected, resolve any bot administrators defined in
        # configuration to their IDs and add to the [admin] role
        foreach ($admin in $this.Configuration.BotAdmins) {
            if ($adminId = $this.RoleManager.ResolveUserToId($admin)) {
                try {
                    $this.RoleManager.AddUserToGroup($adminId, 'Admin')
                } catch {
                    Write-Error $_
                }
            } else {
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, "[Bot:Connect] Unable to resolve ID for admin [$admin]"))
            }
        }
    }

    # Disconnect the bot from the chat network
    [void]Disconnect() {
        $this._Logger.Verbose([LogMessage]::new('[Bot:Disconnect] Disconnecting from backend chat network'))
        $this.Backend.Disconnect()
    }

    # Receive an event from the backend chat network
    [Message]ReceiveMessage() {
        $msg = $this.Backend.ReceiveMessage()
        # The backend MAY return a NULL message. Ignore it
        if ($msg) {
            $this._Logger.Debug([LogMessage]::new('[Bot:ReceiveMessage] Received bot message from chat network. Adding to message queue.', $msg))
            $this.MessageQueue.Enqueue($msg)
        }
        return $msg
    }

    [bool]IsBotCommand([Message]$Message) {
        $firstWord = ($Message.Text -split ' ')[0]
        foreach ($prefix in $this._PossibleCommandPrefixes ) {
            if ($firstWord -match "^$prefix") {
                $this._Logger.Debug([LogMessage]::new('[Bot:IsBotCommand] Message is a bot command.'))
                return $true
            }
        }
        return $false
    }

    # Pull message off queue and pass to message handler
    [void]ProcessMessageQueue() {
        if ($this.MessageQueue.Count -gt 0) {
            while ($this.MessageQueue.Count -ne 0) {
                $msg = $this.MessageQueue.Dequeue()
                $this._Logger.Debug([LogMessage]::new('[Bot:ProcessMessageQueue] Dequeued message', $msg))
                $this.HandleMessage($msg)
            }
        }
    }

    # Determine if the message received from the backend
    # is something the bot should act on
    [void]HandleMessage([Message]$Message) {
        # If message is intended to be a bot command
        # if this is false, and a trigger match is not found
        # then the message is just normal conversation that didn't
        # match a regex trigger. In that case, don't respond with an
        # error that we couldn't find the command
        $isBotCommand = $this.IsBotCommand($Message)
        $cmdSearch = $true
        if (-not $isBotCommand) {
            $cmdSearch = $false
            $this._Logger.Debug([LogMessage]::new('[Bot:HandleMessage] Message is not a bot command. Command triggers WILL NOT be searched.'))
        } else {
            # The message is intended to be a bot command
            $Message = $this.TrimPrefix($Message)
        }

        $commandString = $Message.Text
        $parsedCommand = [CommandParser]::Parse($commandString, $Message)
        $this._Logger.Debug([LogMessage]::new('[Bot:HandleMessage] Parsed bot command', $parsedCommand))
        $response = [Response]::new()
        $response.MessageFrom = $Message.From
        $response.To = $Message.To

        # Match parsed command to a command in the plugin manager
        $pluginCmd = $this.PluginManager.MatchCommand($parsedCommand, $cmdSearch)
        if ($pluginCmd) {

            # Pass in the bot to the module command.
            # We need this for builtin commands
            if ($pluginCmd.Plugin.Name -eq 'Builtin') {
                $parsedCommand.NamedParameters.Add('Bot', $this)
            }

            # Inspect the command and find any parameters that should
            # be provided from the bot configuration
            # Insert these as named parameters
            $configProvidedParams = $this.GetConfigProvidedParameters($pluginCmd)
            foreach ($cp in $configProvidedParams.GetEnumerator()) {
                if (-not $parsedCommand.NamedParameters.ContainsKey($cp.Name)) {
                    $parsedCommand.NamedParameters.Add($cp.Name, $cp.Value)
                }
            }

            $result = $this.DispatchCommand($pluginCmd, $parsedCommand, $Message.From)
            if (-not $result.Success) {

                # Was the command not authorized?
                if (-not $result.Authorized) {
                    $response.Severity = [Severity]::Warning
                    $response.Data = New-PoshBotCardResponse -Type Warning -Text "You do not have authorization to run command [$($pluginCmd.Command.Name)] :(" -Title 'Command Unauthorized'
                } else {
                    # TODO
                    # Handle this better
                    $response.Severity = [Severity]::Error
                    if ($result.Errors.Count -gt 0) {
                        $response.Data = $result.Errors | ForEach-Object {
                            if ($_.Exception) {
                                New-PoshBotCardResponse -Type Error -Text $_.Exception.Message -Title 'Command Exception'
                            } else {
                                New-PoshBotCardResponse -Type Error -Text $_ -Title 'Command Exception'
                            }
                        }
                    } else {
                        $response.Data = New-PoshBotCardResponse -Type Error -Text 'Something bad happened :(' -Title 'Command Error'
                    }
                }
            } else {
                foreach ($r in $result.Output) {
                    if (($r.PSObject.TypeNames[0] -eq 'PoshBot.Text.Response') -or ($r.PSObject.TypeNames[0] -eq 'PoshBot.Card.Response')) {
                        $response.Data += $r
                    } else {
                        $response.Text += $($r | Format-List * | Out-String)
                    }
                }
                #$response.Text = $($result.Output | Format-List * | Out-String)
            }
        } else {
            if ($isBotCommand) {
                $msg = "No command found matching [$commandString]"
                $this._Logger.Info([LogMessage]::new([LogSeverity]::Warning, $msg, $parsedCommand))
                # Only respond with command not found message if configuration allows it.
                if (-not $this.Configuration.MuteUnknownCommand) {
                    $response.Severity = [Severity]::Warning
                    $response.Data = New-PoshBotCardResponse -Type Warning -Text $msg
                }
            }
        }

        # Send response back to user in private (DM) channel if this command
        # is marked to devert responses
        if ($pluginCmd) {
            if ($this.Configuration.SendCommandResponseToPrivate -Contains $pluginCmd.ToString()) {
                $this._Logger.Info([LogMessage]::new("[Bot:HandleMessage] Deverting response from command [$pluginCmd.ToString()] to private channel"))
                $Response.To = "@$($this.RoleManager.ResolveUserToId($Message.From))"
            }
        }

        $this.SendMessage($response)
    }

    # Dispatch the command to the executor
    [CommandResult]DispatchCommand([PluginCommand]$PluginCmd, [ParsedCommand]$ParsedCommand, [string]$CallerId) {
        $result = $this.Executor.ExecuteCommand($PluginCmd, $ParsedCommand, $CallerId)
        return $result
    }

    # Trim the command prefix or any alternate prefix or seperators off the message
    # as we won't need them anymore.
    [Message]TrimPrefix([Message]$Message) {
        if (-not [string]::IsNullOrEmpty($Message.Text)) {
            $Message.Text = $Message.Text.Trim()
            $firstWord = ($Message.Text -split ' ')[0]

            foreach ($prefix in $this._PossibleCommandPrefixes) {
                if ($firstWord -match "^$prefix") {
                    $Message.Text = $Message.Text.TrimStart($prefix).Trim()
                }
            }
        }
        return $Message
    }

    [void]GenerateCommandPrefixList() {
        $this._PossibleCommandPrefixes.Add($this.Configuration.CommandPrefix)
        foreach ($alternatePrefix in $this.Configuration.AlternateCommandPrefixes) {
            $this._PossibleCommandPrefixes.Add($alternatePrefix)
            foreach ($seperator in ($this.Configuration.AlternateCommandPrefixSeperators)) {
                $prefixPlusSeperator = "$alternatePrefix$seperator"
                $this._PossibleCommandPrefixes.Add($prefixPlusSeperator)
            }
        }
    }

    # Send the response to the backend to execute
    [void]SendMessage([Response]$Response) {
        $this.Backend.SendMessage($Response)
    }

    [void]SendMessage([Card]$Response) {
        $this.Backend.SendMessage($Response)
    }

    # Get any parameters with the
    [hashtable]GetConfigProvidedParameters([PluginCommand]$PluginCmd) {

        $command = $PluginCmd.Command.FunctionInfo

        $this._Logger.Debug([LogMessage]::new("[Bot:GetConfigProvidedParameters] Inspecting command [$($PluginCmd.ToString())] for configuration-provided parameters"))
        $configParams = foreach($param in $Command.Parameters.GetEnumerator() | Select-Object -ExpandProperty Value) {
            foreach ($attr in $param.Attributes) {
                if ($attr.GetType().ToString() -eq 'PoshBot.FromConfig') {
                    [ConfigProvidedParameter]::new($attr, $param)
                }
            }
        }

        $configProvidedParams = @{}
        if ($configParams) {
            $pluginConfig = $this.Configuration.PluginConfiguration[$PluginCmd.Plugin.Name]
            if ($pluginConfig) {
                $this._Logger.Info([LogMessage]::new("[Bot:GetConfigProvidedParameters] Inspecting bot configuration for parameter values matching command [$($PluginCmd.ToString())]"))
                foreach ($cp in $configParams) {

                    if (-not [string]::IsNullOrEmpty($cp.Metadata.Name)) {
                        $configParamName = $cp.Metadata.Name
                    } else {
                        $configParamName = $cp.Parameter.Name
                    }

                    if ($pluginConfig.ContainsKey($configParamName)) {
                        $configProvidedParams.Add($cp.Parameter.Name, $pluginConfig[$configParamName])
                    }
                }
            } else {
                # No plugin configuration defined.
                # Unable to provide values for these parameters
            }
        }

        return $configProvidedParams
    }
}
function ConvertFrom-ParameterToken {
    [cmdletbinding()]
    param(
        [parameter(Mandatory = 'true')]
        [string[]]$Tokens
    )

    begin {
        $r = [pscustomobject]@{
            Tokens = $Tokens
            NamedParameters = @{}
            PositionalParameters = (New-Object System.Collections.ArrayList)
        }

    }

    end {
        # Don't start from the first token (0), that is the command name
        for ($x=1; $x -lt $Tokens.Count; $x++) {
            $p = $Tokens[$x]

            if ($p -match '^--') {

                $paramName = $p.TrimStart('--')

                # This is a named parameter (or a switch)
                # If named parameter, the next parameter should not
                # begin with "--". If it does then this parameter should be
                # considered a switch
                if (($tokens[$x+1] -match '^--') -or $x -eq $Tokens.Count-1) {
                    # This is a switch parameter
                    if (-not $r.NamedParameters.ContainsKey($p)) {
                        $r.NamedParameters.Add($paramName, $true)
                    }
                } else {
                    # Assume the item following this parameter is the value
                    # for the parameter
                    if ($tokens[$x+1] -and $tokens[$x+1] -notmatch '^--') {
                        $r.NamedParameters.Add($paramName, $Tokens[$x+1])
                    }
                }
            } else {
                # Positional parameters are items where
                # the previous item ISN'T and parameter name (--param)

                if (($Tokens[$x-1] -notmatch '^--') -and ($x-1 -ge 0)) {
                    $r.PositionalParameters.Add($p) | Out-Null
                }
            }
        }
        $r
    }
}

function Copy-Object {
    # http://stackoverflow.com/questions/7468707/deep-copy-a-dictionary-hashtable-in-powershell
    [outputtype([system.object])]
    [cmdletbinding()]
    param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [object[]]$InputObject
    )

    begin {
        $memStream = New-Object -TypeName IO.MemoryStream
        $formatter = New-Object -TypeName Runtime.Serialization.Formatters.Binary.BinaryFormatter
    }

    process {
        foreach ($item in $InputObject) {
            $formatter.Serialize($memStream, $InputObject)
            $memStream.Position=0
            $formatter.Deserialize($memStream)
        }
    }
}


# https://gallery.technet.microsoft.com/Generic-PowerShell-string-e9ccfe73
function Get-StringToken
{
    <#
    .Synopsis
       Converts a string into individual tokens.
    .DESCRIPTION
       Converts a string into tokens, with customizable behavior around delimiters and handling of qualified (quoted) strings.
    .PARAMETER String
       The string to be parsed. Can be passed in as an array of strings (with each element of the array treated as a separate line), or as a single string containing embedded `r and/or `n characters.
    .PARAMETER Delimiter
       The delimiters separating each token. May be passed as a single string or an array of string; either way, every character in the strings is treated as a delimiter. The default delimiters are spaces and tabs.
    .PARAMETER Qualifier
       The characters that can be used to qualify (quote) tokens that contain embedded delimiters. As with delimiters, may be specified either as an array of strings, or as a single string that contains all legal qualifier characters. Default is double quotation marks.
    .PARAMETER Escape
       The characters that can be used to escape an embedded qualifier inside a qualified token. You do not need to specify the qualifiers themselves (ie, to allow two consecutive qualifiers to embed one in the token); that behavior is handled separately by the -NoDoubleQualifiers switch. Default is no escape characters. Note: An escape character that is NOT followed by the active qualifier is not treated as anything special; the escape character will be included in the token.
    .PARAMETER LineDelimiter
       If -Span is specified, and if the opening and closing qualifers of a token are found in different elements of the -String array, the string specified by -LineDelimiter will be injected into the token. Defaults to "`r`n"
    .PARAMETER NoDoubleQualifier
       By default, the function treats two consecutive qualifiers as one embedded qualifier character in a token. (ie: "a ""token"" string"). Specifying -NoDoubleQualifier disables this behavior, causing only the -Escape characters to be allowed for embedding qualifiers in a token.
    .PARAMETER IgnoreConsecutiveDelimiters
       By default, if the script finds consecutive delimiters, it will output empty strings as tokens. Specifying -IgnoreConsecutiveDelimiters treat consecutive delimiters as one (effectively only outputting non-empty tokens, unless the empty string is qualified / quoted).
    .PARAMETER Span
       Passing the Span switch allows qualified tokens to contain embedded end-of-line characters.
    .PARAMETER GroupLines
       Passing the GroupLines switch causes the function to return an object for each line of input. If the Span switch is also used, multiple lines of text from the input may be merged into one output object.
       Each output object will have a Tokens collection.
    .EXAMPLE
       Get-StringToken -String @("Line 1","Line`t 2",'"Line 3"')
 
       Tokenizes an array of strings using the function's default behavior (spaces and tabs as delimiters, double quotation marks as a qualifier, consecutive delimiters produces an empty token). In this example, six tokens will be output. The single quotes in the example output are not part of the tokens:
 
       'Line'
       '1'
       'Line'
       ''
       '2'
       'Line 3'
    .EXAMPLE
       $strings | Get-StringToken -Delimiter ',' -Qualifier '"' -Span
 
       Pipes a string or string collection to Get-StringToken. Text is treated as comma-delimeted, with double quotation qualifiers, and qualified tokens may span multiple lines. In effect, CSV file format.
    .EXAMPLE
       $strings | Get-StringToken -Qualifier '"' -IgnoreConsecutiveDelimeters -Escape '\' -NoDoubleQualifier
 
       Pipes a string or string collection to Get-StringToken. Uses the default delimiters of tab and space. Double quotes are the qualifier, and embedded quotes must be escaped with a backslash; placing two consecutive double quotes is disabled by the -NoDoubleQualifier argument. Consecutive delimiters are ignored.
    .INPUTS
       [System.String] - The string to be parsed.
    .OUTPUTS
       [System.String] - One string for each token.
       [PSObject] - If the GroupLines switch is used, the function outputs custom objects with a Tokens property. The Tokens property is an array of strings.
    .NOTES
       Author: Dave Wyatt
       Email : dlwyatt115@gmail.com
       Date : 07-Dec-2013
    #>


    #requires -Version 2

    [CmdletBinding()]
    [OutputType([System.String])]
    param (
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [AllowEmptyString()]
        [System.String[]]
        $String,

        [ValidateNotNull()]
        [System.String[]]
        $Delimiter = @("`t",' '),

        [ValidateNotNull()]
        [System.String[]]
        $Qualifier = @('"', "'"),

        [ValidateNotNull()]
        [System.String[]]
        $Escape = @(),

        [ValidateNotNull()]
        [System.String]
        $LineDelimiter = "`r`n",

        [Switch]
        $NoDoubleQualifier,

        [Switch]
        $Span,

        [Switch]
        $GroupLines,

        [Switch]
        $IgnoreConsecutiveDelimiters
    )

    begin
    {
        $currentToken = New-Object System.Text.StringBuilder
        $currentQualifer = $null

        $delimiters = @{}
        foreach ($item in $Delimiter)
        {
            if ($item.Length -gt 1) {
                foreach ($character in $item.GetEnumerator())
                {
                    $delimiters[$character] = $true
                }
            } else {
                $delimiters[[char]$item] = $true
            }
        }

        $qualifiers = @{}
        foreach ($item in $Qualifier)
        {
            if ($item.Length -gt 1) {
                foreach ($character in $item.GetEnumerator())
                {
                    $qualifiers[$character] = $true
                }
            } else {
                $qualifiers[[char]$item] = $true
            }
        }

        $escapeChars = @{}
        foreach ($item in $Escape)
        {
            if ($item.Length -gt 1) {
                foreach ($character in $item.GetEnumerator())
                {
                    $escapeChars[$character] = $true
                }
            } else {
                $escapeChars[[char]$item] = $true
            }
        }

        if ($NoDoubleQualifier)
        {
            $doubleQualifierIsEscape = $false
        }
        else
        {
            $doubleQualifierIsEscape = $true
        }

        $lineGroup = New-Object System.Collections.ArrayList

    } # end begin block

    process
    {
        foreach ($str in $String)
        {
            # If the last $str value was in the middle of building a token when the end of the string was reached,
            # handle it before parsing the current $str.
            if ($currentToken.Length -gt 0)
            {
                if ($null -ne $currentQualifer -and $Span)
                {
                    $null = $currentToken.Append($LineDelimiter)
                }

                else
                {
                    if ($GroupLines)
                    {
                        $null = $lineGroup.Add($currentToken.ToString())
                    }
                    else
                    {
                        Write-Output $currentToken.ToString()
                    }

                    $currentToken.Length = 0
                    $currentQualifer = $null
                }
            }

            if ($GroupLines -and $lineGroup.Count -gt 0)
            {
                Write-Output (New-Object psobject -Property @{
                    Tokens = $lineGroup.ToArray()
                })

                $lineGroup.Clear()
            }

            for ($i = 0; $i -lt $str.Length; $i++)
            {
                $currentChar = $str.Chars($i)

                if ($currentQualifer)
                {
                    # Line breaks in qualified token.
                    if (($currentChar -eq "`n" -or $currentChar -eq "`r") -and -not $Span)
                    {
                        if ($currentToken.Length -gt 0 -or -not $IgnoreConsecutiveDelimiters)
                        {
                            if ($GroupLines)
                            {
                                $null = $lineGroup.Add($currentToken.ToString())
                            }
                            else
                            {
                                Write-Output $currentToken.ToString()
                            }

                            $currentToken.Length = 0
                            $currentQualifer = $null
                        }

                        if ($GroupLines -and $lineGroup.Count -gt 0)
                        {
                            Write-Output (New-Object psobject -Property @{
                                Tokens = $lineGroup.ToArray()
                            })

                            $lineGroup.Clear()
                        }

                        # We're not including the line breaks in the token, so eat the rest of the consecutive line break characters.
                        while ($i+1 -lt $str.Length -and ($str.Chars($i+1) -eq "`r" -or $str.Chars($i+1) -eq "`n"))
                        {
                            $i++
                        }
                    }

                    # Embedded, escaped qualifiers
                    elseif (($escapeChars.ContainsKey($currentChar) -or ($currentChar -eq $currentQualifer -and $doubleQualifierIsEscape)) -and
                             $i+1 -lt $str.Length -and $str.Chars($i+1) -eq $currentQualifer)
                    {
                        $null = $currentToken.Append($currentQualifer)
                        $i++
                    }

                    # Closing qualifier
                    elseif ($currentChar -eq $currentQualifer)
                    {
                        if ($GroupLines)
                        {
                            $null = $lineGroup.Add($currentToken.ToString())
                        }
                        else
                        {
                            Write-Output $currentToken.ToString()
                        }

                        $currentToken.Length = 0
                        $currentQualifer = $null

                        # Eat any non-delimiter, non-EOL text after the closing qualifier, plus the next delimiter. Sets the loop up
                        # to begin processing the next token (or next consecutive delimiter) next time through. End-of-line characters
                        # are left alone, because eating them can interfere with the GroupLines switch behavior.
                        while ($i+1 -lt $str.Length -and $str.Chars($i+1) -ne "`r" -and $str.Chars($i+1) -ne "`n" -and -not $delimiters.ContainsKey($str.Chars($i+1)))
                        {
                            $i++
                        }

                        if ($i+1 -lt $str.Length -and $delimiters.ContainsKey($str.Chars($i+1)))
                        {
                            $i++
                        }
                    }

                    # Token content
                    else
                    {
                        $null = $currentToken.Append($currentChar)
                    }

                } # end if ($currentQualifier)

                else
                {
                    Write-Debug ([int]$currentChar)

                    # Opening qualifier
                    if ($currentToken.ToString() -match '^\s*$' -and $qualifiers.ContainsKey($currentChar))
                    {
                        $currentQualifer = $currentChar
                        $currentToken.Length = 0
                    }

                    # Delimiter
                    elseif ($delimiters.ContainsKey($currentChar))
                    {
                        if ($currentToken.Length -gt 0 -or -not $IgnoreConsecutiveDelimiters)
                        {
                            if ($GroupLines)
                            {
                                $null = $lineGroup.Add($currentToken.ToString())
                            }
                            else
                            {
                                Write-Output $currentToken.ToString()
                            }

                            $currentToken.Length = 0
                            $currentQualifer = $null
                        }
                    }

                    # Line breaks (not treated quite the same as delimiters)
                    elseif ($currentChar -eq "`n" -or $currentChar -eq "`r")
                    {
                        if ($currentToken.Length -gt 0)
                        {
                            if ($GroupLines)
                            {
                                $null = $lineGroup.Add($currentToken.ToString())
                            }
                            else
                            {
                                Write-Output $currentToken.ToString()
                            }

                            $currentToken.Length = 0
                            $currentQualifer = $null
                        }

                        if ($GroupLines -and $lineGroup.Count -gt 0)
                        {
                            Write-Output (New-Object psobject -Property @{
                                Tokens = $lineGroup.ToArray()
                            })

                            $lineGroup.Clear()
                        }
                    }

                    # Token content
                    else
                    {
                        $null = $currentToken.Append($currentChar)
                    }

                } # -not $currentQualifier

            } # end for $i = 0 to $str.Length

        } # end foreach $str in $String

    } # end process block

    end
    {
        if ($currentToken.Length -gt 0)
        {
            if ($GroupLines)
            {
                $null = $lineGroup.Add($currentToken.ToString())
            }
            else
            {
                Write-Output $currentToken.ToString()
            }
        }

        if ($GroupLines -and $lineGroup.Count -gt 0)
        {
            Write-Output (New-Object psobject -Property @{
                Tokens = $lineGroup.ToArray()
            })
        }
    }

}


function Get-PoshBot {
    <#
    .SYNOPSIS
        Gets any currently running instances of PoshBot that are running as background jobs.
    .DESCRIPTION
        PoshBot can be run in the background with PowerShell jobs. This function returns
        any currently running PoshBot instances.
    .PARAMETER Id
        One or more job IDs to retrieve.
    .EXAMPLE
        PS C:\> Get-PoshBot
 
        Id : 5
        Name : PoshBot_3ddfc676406d40fca149019d935f065d
        State : Running
        InstanceId : 3ddfc676406d40fca149019d935f065d
        Config : BotConfiguration
 
    .EXAMPLE
        PS C:\> Get-PoshBot -Id 100
 
        Id : 100
        Name : PoshBot_eab96f2ad147489b9f90e110e02ad805
        State : Running
        InstanceId : eab96f2ad147489b9f90e110e02ad805
        Config : BotConfiguration
 
        Gets the PoshBot job instance with ID 100.
    .INPUTS
        System.Int32
    .OUTPUTS
        PSCustomObject
    .LINK
        Start-PoshBot
    .LINK
        Stop-PoshBot
    #>

    [OutputType([PSCustomObject])]
    [cmdletbinding()]
    param(
        [parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [int[]]$Id = @()
    )

    process {
        if ($Id.Count -gt 0) {
            foreach ($item in $Id) {
                if ($b = $script:botTracker.$item) {
                    [pscustomobject][ordered]@{
                        Id = $item
                        Name = $b.Name
                        State = (Get-Job -Id $b.jobId).State
                        InstanceId = $b.InstanceId
                        Config = $b.Config
                    }
                }
            }
        } else {
            $script:botTracker.GetEnumerator() | ForEach-Object {
                [pscustomobject][ordered]@{
                    Id = $_.Value.JobId
                    Name = $_.Value.Name
                    State = (Get-Job -Id $_.Value.JobId).State
                    InstanceId = $_.Value.InstanceId
                    Config = $_.Value.Config
                }
            }
        }
    }
}

Export-ModuleMember -Function 'Get-PoshBot'


function Get-PoshBotConfiguration {
    <#
    .SYNOPSIS
        Gets a PoshBot configuration from a file.
    .DESCRIPTION
        PoshBot configurations can be stored on the filesytem in PowerShell data (.psd1) files.
        This functions will load that file and return a [BotConfiguration] object.
    .PARAMETER Path
        One or more paths to a PoshBot configuration file.
    .EXAMPLE
        PS C:\> Get-PoshBotConfiguration -Path C:\Users\joeuser\.poshbot\Cherry2000.psd1
 
        Name : Cherry2000
        ConfigurationDirectory : C:\Users\joeuser\.poshbot
        LogDirectory : C:\Users\joeuser\.poshbot\Logs
        PluginDirectory : C:\Users\joeuser\.poshbot
        PluginRepository : {PSGallery}
        ModuleManifestsToLoad : {}
        LogLevel : Debug
        BackendConfiguration : {Token, Name}
        PluginConfiguration : {}
        BotAdmins : {joeuser}
        CommandPrefix : !
        AlternateCommandPrefixes : {bender, hal}
        AlternateCommandPrefixSeperators : {:, ,, ;}
        SendCommandResponseToPrivate : {}
        MuteUnknownCommand : False
 
        Gets the bot configuration located at [C:\Users\joeuser\.poshbot\Cherry2000.psd1].
    .EXAMPLE
        PS C:\> $botConfig = 'C:\Users\joeuser\.poshbot\Cherry2000.psd1' | Get-PoshBotConfiguration
 
        Gets the bot configuration located at [C:\Users\brand\.poshbot\Cherry2000.psd1].
    .INPUTS
        String
    .OUTPUTS
        BotConfiguration
    .LINK
        New-PoshBotConfiguration
    .LINK
        Start-PoshBot
    #>

    [cmdletbinding()]
    param(
        [parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({
            if (Test-Path -Path $_) {
                if ( (Get-Item -Path $_).Extension -eq '.psd1') {
                    $true
                } else {
                    Throw 'Path must be to a valid .psd1 file'
                }
            } else {
                Throw 'Path is not valid'
            }
        })]
        [string[]]$Path = (Join-Path -Path (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot') -ChildPath 'PoshBot.psd1')
    )

    process {
        foreach ($item in $Path) {
            if (Test-Path $item) {
                Write-Verbose -Message "Loading bot configuration from [$item]"
                $hash = Import-PowerShellDataFile -Path $item
                $config = [BotConfiguration]::new()
                $hash.Keys | Foreach-Object {
                    if ($config | Get-Member -MemberType Property -Name $_) {
                        $config.($_) = $hash[$_]
                    }
                }

                $config
            } else {
                Write-Error -Message "Path [$item] is not valid."
            }
        }
    }
}

Export-ModuleMember -Function 'Get-PoshBotConfiguration'


function New-PoshBotCardResponse {
    <#
    .SYNOPSIS
        Tells PoshBot to send a specially formatted response.
    .DESCRIPTION
        Responses from PoshBot commands can either be plain text or formatted. Returning a response with New-PoshBotRepsonse will tell PoshBot
        to craft a specially formatted message when sending back to the chat network.
    .PARAMETER Type
        Specifies a preset color for the card response. If the [Color] parameter is specified as well, it will override this parameter.
 
        | Type | Color | Hex code |
        |---------|--------|----------|
        | Normal | Greed | #008000 |
        | Warning | Yellow | #FFA500 |
        | Error | Red | #FF0000 |
    .PARAMETER Text
        The text response from the command.
    .PARAMETER DM
        Tell PoshBot to redirect the response to a DM channel.
    .PARAMETER Title
        The title of the response. This will be the card title in chat networks like Slack.
    .PARAMETER ThumbnailUrl
        A URL to a thumbnail image to display in the card response.
    .PARAMETER ImageUrl
        A URL to an image to display in the card response.
    .PARAMETER LinkUrl
        Will turn the title into a hyperlink
    .PARAMETER Fields
        A hashtable to display as a table in the card response.
    .PARAMETER COLOR
        The hex color code to use for the card response. In Slack, this will be the color of the left border in the message attachment.
    .EXAMPLE
        function Do-Something {
            [cmdletbinding()]
            param(
                [parameter(mandatory)]
                [string]$MyParam
            )
 
            New-PoshBotCardResponse -Type Normal -Text 'OK, I did something.' -ThumbnailUrl 'https://www.streamsports.com/images/icon_green_check_256.png'
        }
 
        Tells PoshBot to send a formatted response back to the chat network. In Slack for example, this response will be a message attachment
        with a green border on the left, some text and a green checkmark thumbnail image.
    .EXAMPLE
        function Do-Something {
            [cmdletbinding()]
            param(
                [parameter(mandatory)]
                [string]$ComputerName
            )
 
            $info = Get-ComputerInfo -ComputerName $ComputerName -ErrorAction SilentlyContinue
            if ($info) {
                $fields = [ordered]@{
                    Name = $ComputerName
                    OS = $info.OSName
                    Uptime = $info.Uptime
                    IPAddress = $info.IPAddress
                }
                New-PoshBotCardResponse -Type Normal -Fields $fields
            } else {
                New-PoshBotCardResponse -Type Error -Text 'Something bad happended :(' -ThumbnailUrl 'http://p1cdn05.thewrap.com/images/2015/06/don-draper-shrug.jpg'
            }
        }
 
        Attempt to retrieve some information from a given computer and return a card response back to PoshBot. If the command fails for some reason,
        return a card response specified the error and a sad image.
    .OUTPUTS
        PSCustomObject
    .LINK
        New-PoshBotTextResponse
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Scope='Function', Target='*')]
    [cmdletbinding()]
    param(
        [ValidateSet('Normal', 'Warning', 'Error')]
        [string]$Type = 'Normal',

        [switch]$DM,

        [string]$Text = [string]::empty,

        [string]$Title,

        [ValidateScript({
            $uri = $null
            if ([system.uri]::TryCreate($_, [System.UriKind]::Absolute, [ref]$uri)) {
                return $true
            } else {
                $msg = 'ThumbnailUrl must be a valid URL'
                throw [System.Management.Automation.ValidationMetadataException]$msg
            }
        })]
        [string]$ThumbnailUrl,

        [ValidateScript({
            $uri = $null
            if ([system.uri]::TryCreate($_, [System.UriKind]::Absolute, [ref]$uri)) {
                return $true
            } else {
                $msg = 'ImageUrl must be a valid URL'
                throw [System.Management.Automation.ValidationMetadataException]$msg
            }
        })]
        [string]$ImageUrl,

        [ValidateScript({
            $uri = $null
            if ([system.uri]::TryCreate($_, [System.UriKind]::Absolute, [ref]$uri)) {
                return $true
            } else {
                $msg = 'LinkUrl must be a valid URL'
                throw [System.Management.Automation.ValidationMetadataException]$msg
            }
        })]
        [string]$LinkUrl,

        [hashtable]$Fields,

        [ValidateScript({
            if ($_ -match '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$') {
                return $true
            } else {
                $msg = 'Color but be a valid hexidecimal color code e.g. ##008000'
                throw [System.Management.Automation.ValidationMetadataException]$msg
            }
        })]
        [string]$Color = '#D3D3D3'
    )

    $response = [ordered]@{
        PSTypeName = 'PoshBot.Card.Response'
        Text = $Text
        Private = $PSBoundParameters.ContainsKey('Private')
        DM = $PSBoundParameters.ContainsKey('DM')
    }
    if ($PSBoundParameters.ContainsKey('Title')) {
        $response.Title = $Title
    }
    if ($PSBoundParameters.ContainsKey('ThumbnailUrl')) {
        $response.ThumbnailUrl = $ThumbnailUrl
    }
    if ($PSBoundParameters.ContainsKey('ImageUrl')) {
        $response.ImageUrl = $ImageUrl
    }
    if ($PSBoundParameters.ContainsKey('LinkUrl')) {
        $response.LinkUrl = $LinkUrl
    }
    if ($PSBoundParameters.ContainsKey('Fields')) {
        $response.Fields = $Fields
    }
    if ($PSBoundParameters.ContainsKey('Color')) {
        $response.Color = $Color
    } else {
        switch ($Type) {
            'Normal' {
                $response.Color = '#008000'
            }
            'Warning' {
                $response.Color = '#FFA500'
            }
            'Error' {
                $response.Color = '#FF0000'
            }
        }
    }

    [pscustomobject]$response
}

Export-ModuleMember -Function 'New-PoshBotCardResponse'


function New-PoshBotConfiguration {
    <#
    .SYNOPSIS
        Creates a new PoshBot configuration object.
    .DESCRIPTION
        Creates a new PoshBot configuration object.
    .PARAMETER Name
        The name the bot instance will be known as.
    .PARAMETER ConfigurationDirectory
        The directory when PoshBot configuration data will be written to.
    .PARAMETER LogDirectory
        The log directory logs will be written to.
    .PARAMETER PluginDirectory
        The directory PoshBot will look for PowerShell modules.
        This path will be prepended to your $env:PSModulePath.
    .PARAMETER PluginRepository
        One or more PowerShell repositories to look in when installing new plugins (modules).
        These will be the repository name(s) as found in Get-PSRepository.
    .PARAMETER ModuleManifestsToLoad
        One or more paths to module manifest (.psd1) files. These modules will be automatically
        loaded when PoshBot starts.
    .PARAMETER LogLevel
        The level of logging that PoshBot will do.
    .PARAMETER BackendConfiguration
        A hashtable of configuration options required by the backend chat network implementation.
    .PARAMETER PluginConfiguration
        A hashtable of configuration options used by the various plugins (modules) that are installed in PoshBot.
        Each key in the hashtable must be the name of a plugin. The value of that hashtable item will be another hashtable
        with each key matching a parameter name in one or more commands of that module. A plugin command can specifiy that a
        parameter gets its value from this configuration by applying the custom attribute [PoshBot.FromConfig()] on
        the parameter.
 
        The function below is stating that the parameter $MyParam will get its value from the plugin configuration. The user
        running this command in PoshBot does not need to specify this parameter. PoshBot will dynamically resolve and apply
        the matching value from the plugin configuration when the command is executed.
 
        function Get-Foo {
            [cmdletbinding()]
            param(
                [PoshBot.FromConfig()]
                [parameter(mandatory)]
                [string]$MyParam
            )
 
            Write-Output $MyParam
        }
 
        If the function below was part of the Demo plugin, PoshBot will look in the plugin configuration for a key matching Demo
        and a child key matching $MyParam.
 
        Example plugin configuration:
        @{
            Demo = @{
                MyParam = 'bar'
            }
        }
    .PARAMETER BotAdmins
        An array of chat handles that will be granted admin rights in PoshBot. Any user in this array will have full rights in PoshBot. At startup,
        PoshBot will resolve these handles into IDs given by the chat network.
    .PARAMETER CommandPrefix
        The prefix (single character) that must be specified in front of a command in order for PoshBot to recognize the chat message as a bot command.
 
        !get-foo --value bar
    .PARAMETER AlternateCommandPrefixes
        Some users may want to specify alternate prefixes when calling bot comamnds. Use this parameter to specify an array of words that PoshBot
        will also check when parsing a chat message.
 
        bender get-foo --value bar
 
        hal open-doors --type pod
    .PARAMETER AlternateCommandPrefixSeperators
        An array of characters that can also ben used when referencing bot commands.
 
        bender, get-foo --value bar
 
        hal; open-doors --type pod
    .PARAMETER SendCommandResponseToPrivate
        A list of fully qualified (<PluginName>:<CommandName>) plugin commands that will have their responses redirected back to a direct message
        channel with the calling user rather than a shared channel.
 
        @(
            demo:get-foo
            network:ping
        )
    .PARAMETER MuteUnknownCommand
        Instead of PoshBot returning a warning message when it is unable to find a command, use this to parameter to tell PoshBot to return nothing.
    .EXAMPLE
        PS C:\> New-PoshBotConfiguration -Name Cherry2000 -AlternateCommandPrefixes @('Cherry', 'Sam')
 
        Name : Cherry2000
        ConfigurationDirectory : C:\Users\brand\.poshbot
        LogDirectory : C:\Users\brand\.poshbot
        PluginDirectory : C:\Users\brand\.poshbot
        PluginRepository : {PSGallery}
        ModuleManifestsToLoad : {}
        LogLevel : Verbose
        BackendConfiguration : {}
        PluginConfiguration : {}
        BotAdmins : {}
        CommandPrefix : !
        AlternateCommandPrefixes : {Cherry, Sam}
        AlternateCommandPrefixSeperators : {:, ,, ;}
        SendCommandResponseToPrivate : {}
        MuteUnknownCommand : False
 
        Create a new PoshBot configuration with default values except for the bot name and alternate command prefixes that it will listen for.
    .EXAMPLE
        PS C:\> $backend = @{Name = 'SlackBackend'; Token = 'xoxb-569733935137-njOPkyBThqOTTUnCZb7tZpKK'}
        PS C:\> $botParams = @{
                    Name = 'HAL9000'
                    LogLevel = 'Info'
                    BotAdmins = @('JoeUser')
                    BackendConfiguration = $backend
                }
        PS C:\> $myBotConfig = New-PoshBotConfiguration @botParams
        PS C:\> $myBotConfig
 
        Name : HAL9000
        ConfigurationDirectory : C:\Users\brand\.poshbot
        LogDirectory : C:\Users\brand\.poshbot
        PluginDirectory : C:\Users\brand\.poshbot
        PluginRepository : {MyLocalRepo}
        ModuleManifestsToLoad : {}
        LogLevel : Info
        BackendConfiguration : {}
        PluginConfiguration : {}
        BotAdmins : {JoeUser}
        CommandPrefix : !
        AlternateCommandPrefixes : {poshbot}
        AlternateCommandPrefixSeperators : {:, ,, ;}
        SendCommandResponseToPrivate : {}
        MuteUnknownCommand : False
 
        PS C:\> $myBotConfig | Start-PoshBot -AsJob
 
        Create a new PoshBot configuration with a Slack backend. Slack's backend only requires a bot token to be specified. Ensure the person
        with Slack handle 'JoeUser' is a bot admin.
    .OUTPUTS
        BotConfiguration
    .LINK
        Get-PoshBotConfiguration
    .LINK
        Save-PoshBotConfiguration
    .LINK
        New-PoshBotInstance
    .LINK
        Start-PoshBot
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Scope='Function', Target='*')]
    [cmdletbinding()]
    param(
        [string]$Name = 'PoshBot',
        [string]$ConfigurationDirectory = (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot'),
        [string]$LogDirectory = (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot'),
        [string]$PluginDirectory = (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot'),
        [string[]]$PluginRepository = @('PSGallery'),
        [string[]]$ModuleManifestsToLoad = @(),
        [LogLevel]$LogLevel = [LogLevel]::Verbose,
        [hashtable]$BackendConfiguration = @{},
        [hashtable]$PluginConfiguration = @{},
        [string[]]$BotAdmins = @(),
        [char]$CommandPrefix = '!',
        [string[]]$AlternateCommandPrefixes = @('poshbot'),
        [char[]]$AlternateCommandPrefixSeperators = @(':', ',', ';'),
        [string[]]$SendCommandResponseToPrivate = @(),
        [bool]$MuteUnknownCommand = $false
    )

    Write-Verbose -Message 'Creating new PoshBot configuration'
    $config = [BotConfiguration]::new()
    $config.Name = $Name
    $config.ConfigurationDirectory = $ConfigurationDirectory
    $config.AlternateCommandPrefixes = $AlternateCommandPrefixes
    $config.AlternateCommandPrefixSeperators = $AlternateCommandPrefixSeperators
    $config.BotAdmins = $BotAdmins
    $config.CommandPrefix = $CommandPrefix
    $config.LogDirectory = $LogDirectory
    $config.LogLevel = $LogLevel
    $config.BackendConfiguration = $BackendConfiguration
    $config.PluginConfiguration = $PluginConfiguration
    $config.ModuleManifestsToLoad = $ModuleManifestsToLoad
    $config.MuteUnknownCommand = $MuteUnknownCommand
    $config.PluginDirectory = $PluginDirectory
    $config.PluginRepository = $PluginRepository
    $config.SendCommandResponseToPrivate = $SendCommandResponseToPrivate

    $config
}

Export-ModuleMember -Function 'New-PoshBotConfiguration'


function New-PoshBotInstance {
    <#
    .SYNOPSIS
        Creates a new instance of PoshBot
    .DESCRIPTION
        Creates a new instance of PoshBot from an existing configuration (.psd1) file or a configuration object.
    .PARAMETER Configuration
        The bot configuration object to create a new instance from.
    .PARAMETER Path
        The path to a PowerShell data (.psd1) file to create a new instance from.
    .PARAMETER Backend
        The backend object that hosts logic for receiving and sending messages to a chat network.
    .EXAMPLE
        PS C:\> New-PoshBotInstance -Path 'C:\Users\joeuser\.poshbot\Cherry2000.psd1' -Backend $backend
 
        Name : Cherry2000
        Backend : SlackBackend
        Storage : StorageProvider
        PluginManager : PluginManager
        RoleManager : RoleManager
        Executor : CommandExecutor
        MessageQueue : {}
        Configuration : BotConfiguration
 
        Create a new PoshBot instance from configuration file [C:\Users\joeuser\.poshbot\Cherry2000.psd1] and Slack backend object [$backend].
    .EXAMPLE
        PS C:\> $botConfig = Get-PoshBotConfiguration -Path (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot\Cherry2000.psd1')
        PS C:\> $backend = New-PoshBotSlackBackend -Configuration $botConfig.BackendConfiguration
        PS C:\> $myBot = $botConfig | New-PoshBotInstance -Backend $backend
        PS C:\> $myBot | Format-List
 
        Name : Cherry2000
        Backend : SlackBackend
        Storage : StorageProvider
        PluginManager : PluginManager
        RoleManager : RoleManager
        Executor : CommandExecutor
        MessageQueue : {}
        Configuration : BotConfiguration
 
        Gets a bot configuration from the filesytem, creates a chat backend object, and then creates a new bot instance.
    .EXAMPLE
        PS C:\> $botConfig = Get-PoshBotConfiguration -Path (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot\Cherry2000.psd1')
        PS C:\> $backend = $botConfig | New-PoshBotSlackBackend
        PS C:\> $myBotJob = $botConfig | New-PoshBotInstance -Backend $backend | Start-PoshBot -AsJob -PassThru
 
        Gets a bot configuration, creates a Slack backend from it, then creates a new PoshBot instance and starts it as a background job.
    .INPUTS
        String
    .INPUTS
        BotConfiguration
    .OUTPUTS
        Bot
    .LINK
        Get-PoshBotConfiguration
    .LINK
        New-PoshBotSlackBackend
    .LINK
        Start-PoshBot
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Scope='Function', Target='*')]
    [cmdletbinding(DefaultParameterSetName = 'path')]
    param(
        [parameter(ParameterSetName = 'path', ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [ValidateScript({
            if (Test-Path -Path $_) {
                if ( (Get-Item -Path $_).Extension -eq '.psd1') {
                    $true
                } else {
                    Throw 'Path must be to a valid .psd1 file'
                }
            } else {
                Throw 'Path is not valid'
            }
        })]
        [string[]]$Path = (Join-Path -Path (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot') -ChildPath 'PoshBot.psd1'),

        [parameter(Mandatory, ParameterSetName = 'config', ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [BotConfiguration[]]$Configuration,

        [parameter(Mandatory)]
        [Backend]$Backend
    )

    begin {
        $here = $PSScriptRoot
    }

    process {
        if ($PSCmdlet.ParameterSetName -eq 'path') {
            $Configuration = @()
            foreach ($item in $Path) {
                $Configuration += Get-PoshBotConfiguration -Path $item
            }
        }

        foreach ($config in $Configuration) {
            Write-Verbose -Message "Creating bot instance with name [$($config.Name)]"
            [Bot]::new($Backend, $here, $config)
        }
    }
}

Export-ModuleMember -Function 'New-PoshBotInstance'


function New-PoshBotScheduledTask {
    <#
    .SYNOPSIS
        Creates a new scheduled task to run PoshBot in the background.
    .DESCRIPTION
        Creates a new scheduled task to run PoshBot in the background. The scheduled task will always be configured
        to run on startup and to not stop after any time period.
    .PARAMETER Name
        The name for the scheduled task
    .PARAMETER Description
        The description for the scheduled task
    .PARAMETER Path
        The path to the PoshBot configuration file to load and execute
    .PARAMETER Credential
        The credential to run the scheduled task under.
    .PARAMETER PassThru
        Return the newly created scheduled task object
    .PARAMETER Force
        Overwrite a previously created scheduled task
    .EXAMPLE
        PS C:\> $cred = Get-Credential
        PS C:\> New-PoshBotScheduledTask -Name PoshBot -Path C:\PoshBot\myconfig.psd1 -Credential $cred
 
        Creates a new scheduled task to start PoshBot using the configuration file located at C:\PoshBot\myconfig.psd1
        and the specified credential.
    .EXAMPLE
        PS C:\> $cred = Get-Credential
        PC C:\> $params = @{
            Name = 'PoshBot'
            Path = 'C:\PoshBot\myconfig.psd1'
            Credential = $cred
            Description = 'Awesome ChatOps bot'
            PassThru = $true
        }
        PS C:\> $task = New-PoshBotScheduledTask @params
        PS C:\> $task | Start-ScheduledTask
 
        Creates a new scheduled task to start PoshBot using the configuration file located at C:\PoshBot\myconfig.psd1
        and the specified credential then starts the task.
    .OUTPUTS
        Microsoft.Management.Infrastructure.CimInstance#root/Microsoft/Windows/TaskScheduler/MSFT_ScheduledTask
    .LINK
        Get-PoshBotConfiguration
    .LINK
        New-PoshBotConfiguration
    .LINK
        Save-PoshBotConfiguration
    .LINK
        Start-PoshBot
    #>

    [cmdletbinding(SupportsShouldProcess)]
    param(
        [string]$Name = 'PoshBot',

        [string]$Description = 'Start PoshBot',

        [parameter(Mandatory)]
        [ValidateScript({
            if (Test-Path -Path $_) {
                if ( (Get-Item -Path $_).Extension -eq '.psd1') {
                    $true
                } else {
                    Throw 'Path must be to a valid .psd1 file'
                }
            } else {
                Throw 'Path is not valid'
            }
        })]
        [string]$Path,

        [parameter(Mandatory)]
        [pscredential]
        [System.Management.Automation.CredentialAttribute()]
        $Credential,

        [switch]$PassThru,

        [switch]$Force
    )

    # Find the latest version of the module
    if ($mod = Get-Module -Name PoshBot -ListAvailable -Verbose:$false | Sort-Object -Property Version | Select-Object -First 1) {

        if ($Force -or (-not (Get-ScheduledTask -TaskName $Name -ErrorAction SilentlyContinue))) {
            if ($PSCmdlet.ShouldProcess($Name, 'Created PoshBot scheduled task')) {

                $taskParams = @{
                    Description = $Description
                }

                # Determine path to module and scheduled task script
                $modPath = $mod.ModuleBase
                $startScript = Join-Path -Path $modPath -ChildPath '/Task/StartPoshBot.ps1'

                # Scheduled task action
                $arg = "& '$startScript' -Path '$Path'"
                $actionParams = @{
                    Execute = "$($env:SystemDrive)\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"
                    Argument = '-ExecutionPolicy ByPass -Command "' + $arg + '"'
                    WorkingDirectory = $modPath
                }
                $taskParams.Action = New-ScheduledTaskAction @actionParams

                # Scheduled task at logon trigger
                $taskParams.Trigger = New-ScheduledTaskTrigger -AtStartup

                # Scheduled task settings
                $settingsParams = @{
                    AllowStartIfOnBatteries = $true
                    DontStopIfGoingOnBatteries = $true
                    ExecutionTimeLimit = 0
                    RestartCount = 999
                    RestartInterval = (New-TimeSpan -Minutes 1)
                }
                $taskParams.Settings = New-ScheduledTaskSettingsSet @settingsParams

                # Create / register the task
                $registerParams = @{
                    TaskName = $Name
                    Force = $true
                }
                # Scheduled task principal
                $registerParams.User = $Credential.UserName
                $registerParams.Password = $Credential.GetNetworkCredential().Password
                $task = New-ScheduledTask @taskParams
                $newTask = Register-ScheduledTask -InputObject $task @registerParams
                if ($PassThru) {
                    $newTask
                }
            }
        } else {
            Write-Error -Message "Existing task named [$Name] found. To overwrite, use the -Force"
        }
    } else {
        Write-Error -Message 'Unable to find PoshBot module! Can not scheduled the task'
    }
}

Export-ModuleMember -Function 'New-PoshBotScheduledTask'


function New-PoshBotTextResponse {
    <#
    .SYNOPSIS
        Tells PoshBot to handle the text response from a command in a special way.
    .DESCRIPTION
        Responses from PoshBot commands can be sent back to the channel they were posted from (default) or redirected to a DM channel with the
        calling user. This could be useful if the contents the bot command returns are sensitive and should not be visible to all users
        in the channel.
    .PARAMETER Text
        The text response from the command.
    .PARAMETER DM
        Tell PoshBot to redirect the response to a DM channel.
    .EXAMPLE
        function Get-Foo {
            [cmdletbinding()]
            param(
                [parameter(mandatory)]
                [string]$MyParam
            )
 
            New-PoshBotTextResponse -Text $MyParam -DM
        }
 
        When Get-Foo is executed by PoshBot, the text response will be sent back to the calling user as a DM rather than back in the channel the
        command was called from. This could be useful if the contents the bot command returns are sensitive and should not be visible to all users
        in the channel.
    .INPUTS
        String
    .OUTPUTS
        PSCustomObject
    .LINK
        New-PoshBotCardResponse
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Scope='Function', Target='*')]
    [cmdletbinding()]
    param(
        [parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string[]]$Text,

        [switch]$DM
    )

    process {
        foreach ($item in $text) {
            [pscustomobject][ordered]@{
                PSTypeName = 'PoshBot.Text.Response'
                Text = $item
                DM = $PSBoundParameters.ContainsKey('DM')
            }
        }
    }
}

Export-ModuleMember -Function 'New-PoshBotTextResponse'


function Save-PoshBotConfiguration {
    <#
    .SYNOPSIS
        Saves a PoshBot configuration object to the filesystem in the form of a PowerShell data (.psd1) file.
    .DESCRIPTION
        PoshBot configurations can be stored on the filesytem in PowerShell data (.psd1) files.
        This function will save a previously created configuration object to the filesystem.
    .PARAMETER InputObject
        The bot configuration object to save to the filesystem.
    .PARAMETER Path
        The path to a PowerShell data (.psd1) file to save the configuration to.
    .PARAMETER Force
        Overwrites an existing configuration file.
    .PARAMETER PassThru
        Returns the configuration file path.
    .EXAMPLE
        PS C:\> Save-PoshBotConfiguration -InputObject $botConfig
 
        Saves the PoshBot configuration. If now -Path is specified, the configuration will be saved to $env:USERPROFILE\.poshbot\PoshBot.psd1.
    .EXAMPLE
        PS C:\> $botConfig | Save-PoshBotConfig -Path c:\mybot\mybot.psd1
 
        Saves the PoshBot configuration to [c:\mybot\mybot.psd1].
    .EXAMPLE
        PS C:\> $configFile = $botConfig | Save-PoshBotConfig -Path c:\mybot\mybot.psd1 -Force -PassThru
 
        Saves the PoshBot configuration to [c:\mybot\mybot.psd1] and Overwrites existing file. The new file will be returned.
    .INPUTS
        BotConfiguration
    .OUTPUTS
        System.IO.FileInfo
    .LINK
        Get-PoshBotConfiguration
    .LINK
        Start-PoshBot
    #>

    [cmdletbinding(SupportsShouldProcess)]
    param(
        [parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('Configuration')]
        [BotConfiguration]$InputObject,

        [string]$Path = (Join-Path -Path (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot') -ChildPath 'PoshBot.psd1'),

        [switch]$Force,

        [switch]$PassThru
    )

    process {
        if ($PSCmdlet.ShouldProcess($Path, 'Save PoshBot configuration')) {
            $hash = @{}
            $InputObject | Get-Member -MemberType Property | ForEach-Object {
                $hash.Add($_.Name, $InputObject.($_.Name))
            }

            $meta = $hash | ConvertTo-Metadata -WarningAction SilentlyContinue
            if (-not (Test-Path -Path $Path) -or $Force) {
                New-Item -Path $Path -ItemType File -Force | Out-Null

                $meta | Out-file -FilePath $Path -Force -Encoding utf8
                Write-Verbose -Message "PoshBot configuration saved to [$Path]"

                if ($PassThru) {
                    Get-Item -Path $Path | Select-Object -First 1
                }
            } else {
                Write-Error -Message 'File already exists. Use the -Force switch to overwrite the file.'
            }
        }
    }
}

Export-ModuleMember -Function 'Save-PoshBotConfiguration'


function Start-PoshBot {
    <#
    .SYNOPSIS
        Starts a new instance of PoshBot interactively or in a job.
    .DESCRIPTION
        Starts a new instance of PoshBot interactively or in a job.
    .PARAMETER InputObject
        An existing PoshBot instance to start.
    .PARAMETER Configuration
        A PoshBot configuration object to use to start the bot instance.
    .PARAMETER Path
        The path to a PoshBot configuration file.
        A new instance of PoshBot will be created from this file.
    .PARAMETER AsJob
        Run the PoshBot instance in a background job.
    .PARAMETER PassThru
        Return the PoshBot instance Id that is running as a job.
    .EXAMPLE
        PS C:\> Start-PoshBot -Bot $bot
 
        Runs an instance of PoshBot that has already been created interactively in the shell.
    .EXAMPLE
        PS C:\> $bot | Start-PoshBot -Verbose
 
        Runs an instance of PoshBot that has already been created interactively in the shell.
    .EXAMPLE
        PS C:\> $config = Get-PoshBotConfiguration -Path (Join-Path -Path $env:USERPROFILE -ChildPath '.poshbot\MyPoshBot.psd1')
        PS C:\> Start-PoshBot -Config $config
 
        Gets a PoshBot configuration from file and starts the bot interactively.
    .EXAMPLE
        PS C:\> Get-PoshBot -Id 100
 
        Id : 100
        Name : PoshBot_eab96f2ad147489b9f90e110e02ad805
        State : Running
        InstanceId : eab96f2ad147489b9f90e110e02ad805
        Config : BotConfiguration
 
        Gets the PoshBot job instance with ID 100.
    .INPUTS
        Bot
    .INPUTS
        BotConfiguration
    .INPUTS
        String
    .OUTPUTS
        PSCustomObject
    .LINK
        Start-PoshBot
    .LINK
        Stop-PoshBot
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Scope='Function', Target='*')]
    [cmdletbinding(DefaultParameterSetName = 'bot')]
    param(
        [parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'bot')]
        [Alias('Bot')]
        [Bot]$InputObject,

        [parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'config')]
        [BotConfiguration]$Configuration,

        [parameter(Mandatory, ParameterSetName = 'path')]
        [string]$Path,

        [switch]$AsJob,

        [switch]$PassThru
    )

    process {
        switch ($PSCmdlet.ParameterSetName) {
            'bot' {
                $bot = $InputObject
                $Configuration = $bot.Configuration
            }
            'config' {
                $backend = New-PoshBotSlackBackend -Configuration $Configuration.BackendConfiguration
                $bot = New-PoshBotInstance -Backend $backend -Configuration $Configuration
            }
            'path' {
                $Configuration = Get-PoshBotConfiguration -Path $Path
                $backend = New-PoshBotSlackBackend -Configuration $Configuration.BackendConfiguration
                $bot = New-PoshBotInstance -Backend $backend -Configuration $Configuration
            }
        }

        if ($AsJob) {
            $sb = {
                param(
                    [parameter(Mandatory)]
                    $Configuration
                )

                Import-Module PoshBot -ErrorAction Stop

                while($true) {
                    try {
                        $backend = New-PoshBotSlackBackend -Configuration $Configuration.BackendConfiguration
                        $bot = New-PoshBotInstance -Backend $backend -Configuration $Configuration
                        $bot.Start()
                    } catch {
                        Write-Error 'PoshBot crashed :( Restarting...'
                        Start-Sleep -Seconds 5
                    }
                }
            }

            $instanceId = (New-Guid).ToString().Replace('-', '')
            $jobName = "PoshBot_$instanceId"

            #$job = Invoke-Command -ScriptBlock $sb -JobName $jobName -ArgumentList $bot -AsJob
            $job = Start-Job -ScriptBlock $sb -Name $jobName -ArgumentList $Configuration

            # Track the bot instance
            $botTracker = @{
                JobId = $job.Id
                Name = $jobName
                InstanceId = $instanceId
                Config = $Configuration
            }
            $script:botTracker.Add($job.Id, $botTracker)

            if ($PSBoundParameters.ContainsKey('PassThru')) {
                Get-PoshBot -Id $job.Id
            }
        } else {
            $bot.Start()
        }
    }
}

Export-ModuleMember -Function 'Start-Poshbot'


function Stop-Poshbot {
    <#
    .SYNOPSIS
        Stop a currently running PoshBot instance that is running as a background job.
    .DESCRIPTION
        PoshBot can be run in the background with PowerShell jobs. This function stops
        a currently running PoshBot instance.
    .PARAMETER Id
        The job Id of the bot to stop.
    .PARAMETER Force
        Stop PoshBot instance without prompt
    .EXAMPLE
        Stop-PoshBot -Id 101
 
        Stop the bot instance with Id 101.
    .EXAMPLE
        Get-PoshBot | Stop-PoshBot
 
        Gets all running PoshBot instances and stops them.
    .INPUTS
        System.Int32
    .LINK
        Get-PoshBot
    .LINK
        Start-PoshBot
    #>

    [cmdletbinding(SupportsShouldProcess, ConfirmImpact = 'high')]
    param(
        [parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [int[]]$Id,

        [switch]$Force
    )

    begin {
        $remove = @()
    }

    process {
        foreach ($jobId in $Id) {
            if ($Force -or $PSCmdlet.ShouldProcess($jobId, 'Stop PoshBot')) {
                $bot = $script:botTracker[$jobId]
                if ($bot) {
                    Write-Verbose -Message "Stopping PoshBot Id: $jobId"
                    Stop-Job -Id $jobId -Verbose:$false
                    Remove-Job -Id $JobId -Verbose:$false
                    $remove += $jobId
                } else {
                    throw "Unable to find PoshBot instance with Id [$Id]"
                }
            }
        }
    }

    end {
        # Remove this bot from tracking
        $remove | ForEach-Object {
            $script:botTracker.Remove($_)
        }
    }
}

Export-ModuleMember -Function 'Stop-Poshbot'


[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Scope='Class', Target='*')]
class SlackBackend : Backend {

    # The types of message that we care about from Slack
    # All othere will be ignored
    [string[]]$MessageTypes = @('channel_rename', 'message', 'pin_added', 'pin_removed', 'presence_change', 'reaction_added', 'reaction_removed', 'star_added', 'star_removed')

    [int]$MaxMessageLength = 4000

    # Buffer to receive data from websocket
    hidden [Byte[]]$Buffer = (New-Object System.Byte[] 4096)

    # Import some color defs.
    hidden [hashtable]$_PSSlackColorMap = @{
        aliceblue = "#F0F8FF"
        antiquewhite = "#FAEBD7"
        aqua = "#00FFFF"
        aquamarine = "#7FFFD4"
        azure = "#F0FFFF"
        beige = "#F5F5DC"
        bisque = "#FFE4C4"
        black = "#000000"
        blanchedalmond = "#FFEBCD"
        blue = "#0000FF"
        blueviolet = "#8A2BE2"
        brown = "#A52A2A"
        burlywood = "#DEB887"
        cadetblue = "#5F9EA0"
        chartreuse = "#7FFF00"
        chocolate = "#D2691E"
        coral = "#FF7F50"
        cornflowerblue = "#6495ED"
        cornsilk = "#FFF8DC"
        crimson = "#DC143C"
        darkblue = "#00008B"
        darkcyan = "#008B8B"
        darkgoldenrod = "#B8860B"
        darkgray = "#A9A9A9"
        darkgreen = "#006400"
        darkkhaki = "#BDB76B"
        darkmagenta = "#8B008B"
        darkolivegreen = "#556B2F"
        darkorange = "#FF8C00"
        darkorchid = "#9932CC"
        darkred = "#8B0000"
        darksalmon = "#E9967A"
        darkseagreen = "#8FBC8F"
        darkslateblue = "#483D8B"
        darkslategray = "#2F4F4F"
        darkturquoise = "#00CED1"
        darkviolet = "#9400D3"
        deeppink = "#FF1493"
        deepskyblue = "#00BFFF"
        dimgray = "#696969"
        dodgerblue = "#1E90FF"
        firebrick = "#B22222"
        floralwhite = "#FFFAF0"
        forestgreen = "#228B22"
        fuchsia = "#FF00FF"
        gainsboro = "#DCDCDC"
        ghostwhite = "#F8F8FF"
        gold = "#FFD700"
        goldenrod = "#DAA520"
        gray = "#808080"
        green = "#008000"
        greenyellow = "#ADFF2F"
        honeydew = "#F0FFF0"
        hotpink = "#FF69B4"
        indianred = "#CD5C5C"
        indigo = "#4B0082"
        ivory = "#FFFFF0"
        khaki = "#F0E68C"
        lavender = "#E6E6FA"
        lavenderblush = "#FFF0F5"
        lawngreen = "#7CFC00"
        lemonchiffon = "#FFFACD"
        lightblue = "#ADD8E6"
        lightcoral = "#F08080"
        lightcyan = "#E0FFFF"
        lightgoldenrodyellow = "#FAFAD2"
        lightgreen = "#90EE90"
        lightgrey = "#D3D3D3"
        lightpink = "#FFB6C1"
        lightsalmon = "#FFA07A"
        lightseagreen = "#20B2AA"
        lightskyblue = "#87CEFA"
        lightslategray = "#778899"
        lightsteelblue = "#B0C4DE"
        lightyellow = "#FFFFE0"
        lime = "#00FF00"
        limegreen = "#32CD32"
        linen = "#FAF0E6"
        maroon = "#800000"
        mediumaquamarine = "#66CDAA"
        mediumblue = "#0000CD"
        mediumorchid = "#BA55D3"
        mediumpurple = "#9370DB"
        mediumseagreen = "#3CB371"
        mediumslateblue = "#7B68EE"
        mediumspringgreen = "#00FA9A"
        mediumturquoise = "#48D1CC"
        mediumvioletred = "#C71585"
        midnightblue = "#191970"
        mintcream = "#F5FFFA"
        mistyrose = "#FFE4E1"
        moccasin = "#FFE4B5"
        navajowhite = "#FFDEAD"
        navy = "#000080"
        oldlace = "#FDF5E6"
        olive = "#808000"
        olivedrab = "#6B8E23"
        orange = "#FFA500"
        orangered = "#FF4500"
        orchid = "#DA70D6"
        palegoldenrod = "#EEE8AA"
        palegreen = "#98FB98"
        paleturquoise = "#AFEEEE"
        palevioletred = "#DB7093"
        papayawhip = "#FFEFD5"
        peachpuff = "#FFDAB9"
        peru = "#CD853F"
        pink = "#FFC0CB"
        plum = "#DDA0DD"
        powderblue = "#B0E0E6"
        purple = "#800080"
        red = "#FF0000"
        rosybrown = "#BC8F8F"
        royalblue = "#4169E1"
        saddlebrown = "#8B4513"
        salmon = "#FA8072"
        sandybrown = "#F4A460"
        seagreen = "#2E8B57"
        seashell = "#FFF5EE"
        sienna = "#A0522D"
        silver = "#C0C0C0"
        skyblue = "#87CEEB"
        slateblue = "#6A5ACD"
        slategray = "#708090"
        snow = "#FFFAFA"
        springgreen = "#00FF7F"
        steelblue = "#4682B4"
        tan = "#D2B48C"
        teal = "#008080"
        thistle = "#D8BFD8"
        tomato = "#FF6347"
        turquoise = "#40E0D0"
        violet = "#EE82EE"
        wheat = "#F5DEB3"
        white = "#FFFFFF"
        whitesmoke = "#F5F5F5"
        yellow = "#FFFF00"
        yellowgreen = "#9ACD32"
    }

    SlackBackend ([string]$Token) {
        Import-Module PSSlack -Verbose:$false -ErrorAction Stop

        $config = [ConnectionConfig]::new()
        $secToken = $Token | ConvertTo-SecureString -AsPlainText -Force
        $config.Credential = New-Object System.Management.Automation.PSCredential('asdf', $secToken)
        $conn = [SlackConnection]::New()
        $conn.Config = $config
        $this.Connection = $conn
    }

    [void]Connect() {
        $this.Connection.Connect()
        $this.BotId = $this.GetBotIdentity()
        $this.LoadUsers()
        $this.LoadRooms()
    }

    # Receive a message from the websocket
    [Message]ReceiveMessage() {
        [Message]$msg = $null
        try {
            $ct = New-Object System.Threading.CancellationToken
            $taskResult = $null
            do {
                $taskResult = $this.Connection.WebSocket.ReceiveAsync($this.buffer, $ct)
                while (-not $taskResult.IsCompleted) {
                    Start-Sleep -Milliseconds 100
                }
            } until (
                $taskResult.Result.Count -lt 4096
            )
            $jsonResult = [System.Text.Encoding]::UTF8.GetString($this.buffer, 0, $taskResult.Result.Count)

            if ($null -ne $jsonResult -and $jsonResult -ne [string]::Empty) {
                Write-Debug -Message "[SlackBackend:ReceiveMessage] Received `n$jsonResult"

                # Strip out Slack's URI formatting
                $jsonResult = $this._SanitizeURIs($jsonResult)

                $slackMessage = $jsonResult | ConvertFrom-Json
                if ($slackMessage) {
                    # We only care about certain message types from Slack
                    if ($slackMessage.Type -in $this.MessageTypes) {
                        $msg = [Message]::new()

                        # Set the message type and optionally the subtype
                        #$msg.Type = $slackMessage.type
                        switch ($slackMessage.type) {
                            'channel_rename' {
                                $msg.Type = [MessageType]::ChannelRenamed
                            }
                            'message' {
                                $msg.Type = [MessageType]::Message
                            }
                            'pin_added' {
                                $msg.Type = [MessageType]::PinAdded
                            }
                            'pin_removed' {
                                $msg.Type = [MessageType]::PinRemoved
                            }
                            'presence_change' {
                                $msg.Type = [MessageType]::PresenceChange
                            }
                            'reaction_added' {
                                $msg.Type = [MessageType]::ReactionAdded
                            }
                            'reaction_removed' {
                                $msg.Type = [MessageType]::ReactionRemoved
                            }
                            'star_added' {
                                $msg.Type = [MessageType]::StarAdded
                            }
                            'star_removed' {
                                $msg.Type = [MessageType]::StarRemoved
                            }
                        }

                        if ($slackMessage.subtype) {
                            switch ($slackMessage.subtype) {
                                'channel_join' {
                                    $msg.Subtype = [MessageSubtype]::ChannelJoined
                                }
                                'channel_leave' {
                                    $msg.Subtype = [MessageSubtype]::ChannelLeft
                                }
                                'channel_name' {
                                    $msg.Subtype = [MessageSubtype]::ChannelRenamed
                                }
                                'channel_purpose' {
                                    $msg.Subtype = [MessageSubtype]::ChannelPurposeChanged
                                }
                                'channel_topic' {
                                    $msg.Subtype = [MessageSubtype]::ChannelTopicChanged
                                }
                            }
                        }

                        $msg.RawMessage = $slackMessage
                        if ($slackMessage.text)    { $msg.Text = $slackMessage.text }
                        if ($slackMessage.channel) { $msg.To   = $slackMessage.channel }
                        if ($slackMessage.user)    { $msg.From = $slackMessage.user }

                        # Sometimes the message is nested in a 'message' subproperty. This could be
                        # if the message contained a link that was unfurled. We would receive a
                        # 'message_changed' message and need to look in the 'message' subproperty
                        # to see who the message was from. Slack is weird
                        # https://api.slack.com/events/message/message_changed
                        if ($slackMessage.message) {
                            if ($slackMessage.message.user) {
                                $msg.From = $slackMessage.message.user
                            }
                            if ($slackMessage.message.text) {
                                $msg.Text = $slackMessage.message.text
                            }
                        }

                        if (-not $this.MsgFromBot($msg.From)) {
                            return $msg
                        } else {
                            # Don't process messages that came from the bot
                            # That could cause a feedback loop
                            return $null
                        }

                    }
                }
            }
        } catch {
            Write-Error $_
        }
        return $msg
    }

    # Send a Slack ping
    [void]Ping() {
        $msg = @{
            id = 1
            type = 'ping'
            time = [System.Math]::Truncate((Get-Date -Date (Get-Date) -UFormat %s))
        }
        $json = $msg | ConvertTo-Json
        $bytes = ([System.Text.Encoding]::UTF8).GetBytes($json)
        Write-Debug -Message '[SlackBackend:Ping]: One ping only Vasili'
        $cts = New-Object System.Threading.CancellationTokenSource -ArgumentList 5000

        $task = $this.Connection.WebSocket.SendAsync($bytes, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $cts.Token)
        do { Start-Sleep -Milliseconds 100 }
        until ($task.IsCompleted)
        #$result = $this.Connection.WebSocket.SendAsync($bytes, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $cts.Token).GetAwaiter().GetResult()
    }

    [void]SendMessage([Card]$Response) {
        $channelId = $this.ResolveChannelId($Response.To)
        if ($channelId) {
            $cardParams = @{
                #Color = $this._PSSlackColorMap.green
                Fallback = $Response.Text
                Text = $Response.Text
                MarkDownFields = 'text'
            }
            if ($Response.Title) { $cardParams.Title = $Response.Title }
            if ($Response.Summary) { $cardParams.PreText = $Response.Summary }
            if ($Response.Link) { $cardParams.TitleLink = $Response.Link }
            if ($Response.ThumbnailUrl) { $cardParams.ThumbURL = $Response.ThumbnailUrl }
            if ($Response.Fields) {
                # Convert hashtable to what Slack expects
                $fields = @()
                foreach($key in $Response.Fields.Keys){
                    $fields += @{
                        title = $key
                        value = $Response.Fields[$key]
                        short = $true
                    }
                }
                $cardParams.Fields = $fields
            }
            $msgAtt = New-SlackMessageAttachment @cardParams

            # Set severity of response
            switch ($Response.Severity) {
                'Success' {
                    $msgAtt.color = $this._PSSlackColorMap.green
                }
                'Warning' {
                    $msgAtt.color = $this._PSSlackColorMap.orange
                }
                'Error' {
                    $msgAtt.color = $this._PSSlackColorMap.red
                }
                'None' {
                    # no color
                }
            }

            $msg = $msgAtt | New-SlackMessage -Channel $Response.To -AsUser
            $slackResponse = $msg | Send-SlackMessage -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Verbose:$false
            Write-Verbose "[SlackBackend:SendMessage] Result: $($slackResponse | Format-List * | Out-String)"
        } else {
            Write-Error -Message "[SlackBackend:SendMessage] Unable to resolve channel [$($Response.To))]"
        }
    }

    [void]SendMessage([Response]$Response) {
        if ($Response.Data.Count -gt 0) {
            # Process our custom responses
            foreach ($customResponse in $Response.Data) {

                [string]$sendTo = $Response.To
                if ($customResponse.DM -eq $true) {
                    $sendTo = "@$($this.UserIdToUsername($Response.MessageFrom))"
                }

                if ($customResponse.PSObject.TypeNames[0] -eq 'PoshBot.Card.Response') {

                    $chunks = $this._ChunkString($customResponse.Text)
                    Write-Verbose "Split response into [$($chunks.Count)] chunks"
                    $x = 0
                    foreach ($chunk in $chunks) {
                        $attParams = @{
                            MarkdownFields = 'text'
                            Color = $customResponse.Color
                        }
                        $fbText = 'no data'
                        if (-not [string]::IsNullOrEmpty($chunk.Text)) {
                            Write-Verbose "response size: $($chunk.Text.Length)"
                            $fbText = $chunk.Text
                        }
                        $attParams.Fallback = $fbText
                        if ($customResponse.Title) {

                            # If we chunked up the response, only display the title on the first one
                            if ($x -eq 0) {
                                $attParams.Title = $customResponse.Title
                            }
                        }
                        if ($customResponse.ImageUrl) {
                            $attParams.ImageURL = $customResponse.ImageUrl
                        }
                        if ($customResponse.ThumbnailUrl) {
                            $attParams.ThumbURL = $customResponse.ThumbnailUrl
                        }
                        if ($customResponse.LinkUrl) {
                            $attParams.TitleLink = $customResponse.LinkUrl
                        }
                        if ($customResponse.Fields) {
                            $arr = New-Object System.Collections.ArrayList
                            foreach ($key in $customResponse.Fields.Keys) {
                                $arr.Add(
                                    @{
                                        title = $key;
                                        value = $customResponse.Fields[$key];
                                        short = $true
                                    }
                                )
                            }
                            $attParams.Fields = $arr
                        }

                        if (-not [string]::IsNullOrEmpty($chunk)) {
                            $attParams.Text = '```' + $chunk + '```'
                        } else {
                            $attParams.Text = [string]::Empty
                        }
                        $att = New-SlackMessageAttachment @attParams
                        $msg = $att | New-SlackMessage -Channel $sendTo -AsUser
                        $slackResponse = $msg | Send-SlackMessage -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Verbose:$false
                    }
                } elseif ($customResponse.PSObject.TypeNames[0] -eq 'PoshBot.Text.Response') {
                    $slackResponse = Send-SlackMessage -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Channel $sendTo -Text $customResponse.Text -Verbose:$false -AsUser
                }
            }
        }

        if ($Response.Text.Count -gt 0) {
            foreach ($t in $Response.Text) {
                $slackResponse = Send-SlackMessage -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Channel $Response.To -Text $t -Verbose:$false -AsUser
            }
        }
    }

    [string]ResolveChannelId([string]$ChannelName) {
        if ($ChannelName -match '^#') {
            $ChannelName = $ChannelName.TrimStart('#')
        }
        $channelId = ($this.Connection.LoginData.channels | Where-Object name -eq $ChannelName).id
        if (-not $ChannelId) {
            $channelId = ($this.Connection.LoginData.channels | Where-Object id -eq $ChannelName).id
        }
        return $channelId
    }

    [void]LoadUsers() {
        $allUsers = Get-Slackuser -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Verbose:$false
        $allUsers | ForEach-Object {
            $user = [SlackPerson]::new()
            $user.Id = $_.ID
            $user.Nickname = $_.Name
            $user.FullName = $_.RealName
            $user.FirstName = $_.FirstName
            $user.LastName = $_.LastName
            $user.Email = $_.Email
            $user.Phone = $_.Phone
            $user.Skype = $_.Skype
            $user.IsBot = $_.IsBot
            $user.IsAdmin = $_.IsAdmin
            $user.IsOwner = $_.IsOwner
            $user.IsPrimaryOwner = $_.IsPrimaryOwner
            $user.IsUltraRestricted = $_.IsUltraRestricted
            $user.Status = $_.Status
            $user.TimeZoneLabel = $_.TimeZoneLabel
            $user.TimeZone = $_.TimeZone
            $user.Presence = $_.Presence
            $user.Deleted = $_.Deleted
            Write-Verbose -Message "[SlackBackend:LoadUsers] Adding user: $($_.ID):$($_.Name)"
            $this.Users[$_.ID] =  $user
        }

        foreach ($key in $this.Users.Keys) {
            if ($key -notin $allUsers.ID) {
                $this.Users.Remove($key)
            }
        }
    }

    [void]LoadRooms() {
        $allChannels = Get-SlackChannel -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -ExcludeArchived -Verbose:$false

        $allChannels | ForEach-Object {
            $channel = [SlackChannel]::new()
            $channel.Id = $_.ID
            $channel.Name = $_.Name
            $channel.Topic = $_.Topic
            $channel.Purpose = $_.Purpose
            $channel.Created = $_.Created
            $channel.Creator = $_.Creator
            $channel.IsArchived = $_.IsArchived
            $channel.IsGeneral = $_.IsGeneral
            $channel.MemberCount = $_.MemberCount
            foreach ($member in $_.Members) {
                $channel.Members.Add($member, $null)
            }
            Write-Verbose -Message "[SlackBackend:LoadRooms] Adding channel: $($_.ID):$($_.Name)"
            $this.Rooms[$_.ID] = $channel
        }

        foreach ($key in $this.Rooms.Keys) {
            if ($key -notin $allChannels.ID) {
                $this.Rooms.Remove($key)
            }
        }
    }

    [string]GetBotIdentity() {
        return $this.Connection.LoginData.self.id
    }

    [bool]MsgFromBot([string]$From) {
        return $this.BotId -eq $From
    }

    [SlackPerson]GetUser([string]$UserId) {
        $user = $this.Users[$UserId]
        if ($user) {
            return $user
        } else {
            $this.LoadUsers()
            return $this.Users[$UserId]
        }
    }

    [string]UsernameToUserId([string]$Username) {
        $Username = $Username.TrimStart('@')
        $user = (Get-SlackUser -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Name $Username -Verbose:$false -ErrorAction SilentlyContinue)
        if ($user) {
            # Reload our user cache if we don't know about this user
            if (-not $this.Users.ContainsKey($user.Id)) {
                $this.LoadUsers()
            }
            return $user.Id
        } else {
            return $null
        }
    }

    [string]UserIdToUsername([string]$UserId) {
        if ($this.Users.ContainsKey($UserId)) {
            return $this.Users[$UserId].Nickname
        } else {
            $this.LoadUsers()
            return $this.Users[$UserId].Nickname
        }
    }

    hidden [string] _SanitizeURIs([string]$Text) {
        $sanitizedText = $Text -replace '<([^\|>]+)\|([^\|>]+)>', '$2'
        $sanitizedText = $sanitizedText -replace '<(http([^>]+))>', '$1'
        return $sanitizedText
    }

    hidden [System.Collections.ArrayList] _ChunkString([string]$Text) {
        return [regex]::Split($Text, "(?<=\G.{$($this.MaxMessageLength)})", [System.Text.RegularExpressions.RegexOptions]::Singleline)
    }
}

function New-PoshBotSlackBackend {
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Scope='Function', Target='*')]
    [cmdletbinding()]
    param(
        [parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [Alias('BackendConfiguration')]
        [hashtable[]]$Configuration
    )

    process {
        foreach ($item in $Configuration) {
            if (-not $item.Token) {
                throw 'Configuration is missing [Token] parameter'
            } else {
                Write-Verbose 'Creating new Slack backend instance'
                $backend = [SlackBackend]::new($item.Token)
                if ($item.Name) {
                    $backend.Name = $item.Name
                }
                $backend
            }
        }
    }
}

Export-ModuleMember -Function 'New-PoshBotSlackBackend'

class SlackChannel : Room {
    [datetime]$Created
    [string]$Creator
    [bool]$IsArchived
    [bool]$IsGeneral
    [int]$MemberCount
    [string]$Purpose
}

class SlackConnection : Connection {

    [System.Net.WebSockets.ClientWebSocket]$WebSocket
    #[System.Threading.CancellationTokenSource]$CTS
    [pscustomobject]$LoginData
    [string]$UserName
    [string]$Domain

    [string]$WebSocketUrl

    [bool]$Connected

    SlackConnection() {
        $this.WebSocket = New-Object System.Net.WebSockets.ClientWebSocket
        $this.WebSocket.Options.KeepAliveInterval = 5
    }

    [void]Connect() {
        if ($this.WebSocket.State -ne [System.Net.WebSockets.WebSocketState]::Open) {
            $this.RtmConnect()
            $this.ConnectWebSocket()
        }
    }

    [void]RtmConnect() {
        $token = $this.Config.Credential.GetNetworkCredential().Password
        $url = "https://slack.com/api/rtm.start?token=$($token)&pretty=1"
        try {
            $r = Invoke-RestMethod -Uri $url -Method Get -Verbose:$false
            $this.LoginData = $r
            if ($r.ok) {
                Write-Verbose -Message "[SlackConnection:RtmConnect] Successfully authenticated to Slack at [$($r.Url)]"
                $this.WebSocketUrl = $r.url
                $this.Domain = $r.team.domain
                $this.UserName = $r.self.name
            } else {
                Write-Error '[SlackConnection:RtmConnect] Slack login error'
            }
        } catch {
            throw $_
        }
    }

    [void]ConnectWebSocket() {
        Write-Verbose "[SlackConnection:ConnectWebSocket] Connecting to websocket at [$($this.WebSocketUrl)]"
        #$this.WebSocket = New-Object System.Net.WebSockets.ClientWebSocket
        #$this.WebSocket.Options.KeepAliveInterval = 5

        #$r = $this.WebSocket.ConnectAsync($this.WebSocketUrl, $this.CTS.Token).GetAwaiter().GetResult()
        # Connect to websocket
        $cts = New-Object System.Threading.CancellationTokenSource
        $task = $this.WebSocket.ConnectAsync($this.WebSocketUrl, $cts.Token)
        do { Start-Sleep -Milliseconds 100 }
        until ($task.IsCompleted)

        $this.Connected = $true
        $this.Status = [ConnectionStatus]::Connected
    }

    [void]Disconnect([System.Net.WebSockets.WebSocketCloseStatus]$Reason = [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure) {
        Write-Verbose -Message '[SlackConnection:Disconnect] Closing websocket'
        #$cs = [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure
        $cts = New-Object System.Threading.CancellationTokenSource
        $this.WebSocket.CloseAsync($Reason, 'Closing connection', $cts.Token)
        $this.Connected = $false
        $this.Status = [ConnectionStatus]::Disconnected
    }
}

enum SlackMessageType {
    Normal
    Error
    Warning
}

class SlackMessage : Message {

    [SlackMessageType]$MessageType = [SlackMessageType]::Normal

    SlackMessage(
        [string]$To,
        [string]$From,
        [string]$Body = [string]::Empty
    ) {
        $this.To = $To
        $this.From = $From
        $this.Body = $Body
    }

}


class SlackPerson : Person {
    [string]$Email
    [string]$Phone
    [string]$Skype
    [bool]$IsBot
    [bool]$IsAdmin
    [bool]$IsOwner
    [bool]$IsPrimaryOwner
    [bool]$IsRestricted
    [bool]$IsUltraRestricted
    [string]$Status
    [string]$TimeZoneLabel
    [string]$TimeZone
    [string]$Presence
    [bool]$Deleted
}