poshbot.psm1
# Track bot instnace(s) running as PS job $script:botTracker = @{} # 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 ReactionType { Success Failure Processing Custom } # Unit of time for scheduled commands enum TimeInterval { Days Hours Minutes Seconds } 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 [datetime]$Time # The date/time (UTC) the message was received [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 [Message]Clone () { $newMsg = [Message]::New() foreach ($prop in ($this | Get-Member -MemberType Property)) { if ('Clone' -in ($this.$($prop.Name) | Get-Member -MemberType Method -ErrorAction Ignore).Name) { $newMsg.$($prop.Name) = $this.$($prop.Name).Clone() } else { $newMsg.$($prop.Name) = $this.$($prop.Name) } } return $newMsg } } 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 [datetime]$Time = (Get-Date).ToUniversalTime() [string]$From = $null [string]$To = $null [Message]$OriginalMessage } class CommandParser { [ParsedCommand] static Parse([Message]$Message) { $commandString = [string]::Empty if (-not [string]::IsNullOrEmpty($Message.Text)) { $commandString = $Message.Text.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 ($Message.Type -eq [MessageType]::Message -and $Message.SubType -eq [MessageSubtype]::None ) { $plugin = $command.Split(':')[0] } $command = $command.Split(':')[1] if (-not $command) { $command = $plugin $plugin = $null } # Create the ParsedCommand instance $parsedCommand = [ParsedCommand]::new() $parsedCommand.CommandString = $commandString $parsedCommand.Plugin = $plugin $parsedCommand.Command = $command $parsedCommand.OriginalMessage = $Message $parsedCommand.Time = $Message.Time if ($Message.To) { $parsedCommand.To = $Message.To } if ($Message.From) { $parsedCommand.From = $Message.From } # Parse the message text using AST into named and positional parameters try { $positionalParams = @() $namedParams = @{} if (-not [string]::IsNullOrEmpty($commandString)) { # Replace '--<ParamName>' with '-<ParamName' so AST works $astCmdStr = $commandString -replace '(--([a-zA-Z]))', '-$2' $ast = [System.Management.Automation.Language.Parser]::ParseInput($astCmdStr, [ref]$null, [ref]$null) $commandAST = $ast.FindAll({$args[0] -as [System.Management.Automation.Language.CommandAst]},$false) for ($x = 1; $x -lt $commandAST.CommandElements.Count; $x++) { $element = $commandAST.CommandElements[$x] if ($element -is [System.Management.Automation.Language.CommandParameterAst]) { $paramName = $element.ParameterName $paramValues = @() $y = 1 # If the element after this one is another CommandParameterAst or this # is the last element then assume this parameter is a [switch] if ((-not $commandAST.CommandElements[$x+1]) -or ($commandAST.CommandElements[$x+1] -is [System.Management.Automation.Language.CommandParameterAst])) { $paramValues = $true } else { # Inspect the elements immediately after this CommandAst as they are values # for a named parameter and pull out the values (array, string, bool, etc) do { $paramValues += $commandAST.CommandElements[$x+$y].SafeGetValue() $y++ } until ((-not $commandAST.CommandElements[$x+$y]) -or $commandAST.CommandElements[$x+$y] -is [System.Management.Automation.Language.CommandParameterAst]) } if ($paramValues.Count -eq 1) { $paramValues = $paramValues[0] } $namedParams.Add($paramName, $paramValues) $x += $y-1 } else { # This element is a positional parameter value so just get the value $positionalParams += $element.SafeGetValue() } } } $parsedCommand.NamedParameters = $namedParams $parsedCommand.PositionalParameters = $positionalParams } catch { Write-Error -Message "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-Warning -Message "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 hidden [string[]]$_AdminPermissions = @('manage-roles', 'show-help' ,'view', 'view-role', 'view-group', 'manage-plugins', 'manage-groups', 'manage-permissions', 'manage-schedules') 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')) $this.LoadState() # Create the initial state of the [Admin] role ONLY if it didn't get loaded from storage # This could be because this is the first time the bot has run and [roles.psd1] doesn't exist yet. # The bot admin could have modified the permissions for the role and we want to respect those changes if (-not $this.Roles['Admin']) { # Create the builtin Admin role and add all the permissions defined in the [Builtin] module $adminrole = [Role]::New('Admin', 'Bot administrator role') # TODO # Get the builtin permissions from the module manifest rather than hard coding them in the class $this._AdminPermissions | 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.SaveState() } else { # Make sure all the admin permissions are added to the 'Admin' role # This is so if we need to add any permissions in future versions, they will automatically # be added to the role $adminRole = $this.Roles['Admin'] foreach ($perm in $this._AdminPermissions) { if (-not $adminRole.Permissions.ContainsKey($perm)) { $p = [Permission]::new($perm, 'Builtin') $adminRole.AddPermission($p) } } } } # 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) } # 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 [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Scope='Function', Target='*')] [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Scope='Function', Target='*')] class Command { # Unique (to the plugin) name of the command [string]$Name [string[]]$Aliases = @() [string]$Description [Trigger[]]$Triggers = @() [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 # Context for who/how the command was called $global:PoshBotContext = [pscustomobject]@{ Plugin = $options.ParsedCommand.Plugin Command = $options.ParsedCommand.Command From = $options.ParsedCommand.From To = $options.ParsedCommand.To ConfigurationDirectory = $options.ConfigurationDirectory ParsedCommand = $options.ParsedCommand } & $func @named @pos } [string]$sb = [string]::Empty $options = @{ NamedParameters = $ParsedCommand.NamedParameters PositionalParameters = $ParsedCommand.PositionalParameters ManifestPath = $this.ManifestPath Function = $this.FunctionInfo ParsedCommand = $ParsedCommand ConfigurationDirectory = $script:ConfigurationDirectory } 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]AddPermission([Permission]$Permission) { $this.AccessFilter.AddPermission($Permission) } [void]RemovePermission([Permission]$Permission) { $this.AccessFilter.RemovePermission($Permission) } # Search all the triggers for this command and return TRUE if we have a match # with the parsed command [bool]TriggerMatch([ParsedCommand]$ParsedCommand, [bool]$CommandSearch = $true) { $match = $false foreach ($trigger in $this.Triggers) { switch ($trigger.Type) { 'Command' { if ($CommandSearch) { # Command tiggers only work with normal messages received from chat network if ($ParsedCommand.OriginalMessage.Type -eq [MessageType]::Message) { if ($trigger.Trigger -eq $ParsedCommand.Command) { $match = $true break } } } } 'Event' { if ($trigger.MessageType -eq $ParsedCommand.OriginalMessage.Type) { if ($trigger.MessageSubtype -eq $ParsedCommand.OriginalMessage.Subtype) { $match = $true break } } } 'Regex' { if ($ParsedCommand.CommandString -match $trigger.Trigger) { $match = $true break } } } } return $match } } 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)" } } # Represents the state of a currently executing command class CommandExecutionContext { [string]$Id = (New-Guid).ToString() [bool]$Complete = $false [CommandResult]$Result [string]$FullyQualifiedCommandName [Command]$Command [ParsedCommand]$ParsedCommand [Message]$Message [bool]$IsJob [datetime]$Started [datetime]$Ended [object]$Job } # In charge of executing and tracking progress of commands class CommandExecutor { [RoleManager]$RoleManager hidden [Bot]$_bot [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, [Bot]$Bot) { $this.RoleManager = $RoleManager $this._bot = $Bot } # Execute a command [void]ExecuteCommand([PluginCommand]$PluginCmd, [ParsedCommand]$ParsedCommand, [Message]$Message) { $cmdExecContext = [CommandExecutionContext]::new() $cmdExecContext.Started = (Get-Date).ToUniversalTime() $cmdExecContext.Result = [CommandResult]::New() $cmdExecContext.Command = $pluginCmd.Command $cmdExecContext.FullyQualifiedCommandName = $pluginCmd.ToString() $cmdExecContext.ParsedCommand = $ParsedCommand $cmdExecContext.Message = $Message # Verify command is not disabled if (-not $cmdExecContext.Command.Enabled) { $err = [CommandDisabled]::New("Command [$($cmdExecContext.Command.Name)] is disabled") $cmdExecContext.Complete = $true $cmdExecContext.Ended = (Get-Date).ToUniversalTime() $cmdExecContext.Result.Success = $false $cmdExecContext.Result.Errors += $err Write-Error -Exception $err $this.TrackJob($cmdExecContext) return } # 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 ([TriggerType]::Command -in $cmdExecContext.Command.Triggers.Type ) { if (-not $this.ValidateMandatoryParameters($ParsedCommand, $cmdExecContext.Command)) { $msg = "Mandatory parameters for [$($cmdExecContext.Command.Name)] not provided.`nUsage:`n" foreach ($usage in $cmdExecContext.Command.Usage) { $msg += " $usage`n" } $err = [CommandRequirementsNotMet]::New($msg) $cmdExecContext.Complete = $true $cmdExecContext.Ended = (Get-Date).ToUniversalTime() $cmdExecContext.Result.Success = $false $cmdExecContext.Result.Errors += $err Write-Error -Exception $err $this.TrackJob($cmdExecContext) return } } # If command is [command] type verify that the caller is authorized to execute it if ([TriggerType]::Command -in $cmdExecContext.Command.Triggers.Type ) { $authorized = $cmdExecContext.Command.IsAuthorized($Message.From, $this.RoleManager) } else { $authorized = $true } if ($authorized) { # Add reaction telling the user that the command is being executed if ($this._bot.Configuration.AddCommandReactions) { $this._bot.Backend.AddReaction($Message, [ReactionType]::Processing) } if ($cmdExecContext.Command.AsJob) { # Kick off job and add to job tracker $cmdExecContext.IsJob = $true $cmdExecContext.Job = $cmdExecContext.Command.Invoke($ParsedCommand, $true) $cmdExecContext.Complete = $false } else { # Run command in current session and get results # This should only be 'builtin' commands try { $cmdExecContext.IsJob = $false $hash = $cmdExecContext.Command.Invoke($ParsedCommand, $false) $cmdExecContext.Complete = $true $cmdExecContext.Ended = (Get-Date).ToUniversalTime() $cmdExecContext.Result.Errors = $hash.Error $cmdExecContext.Result.Streams.Error = $hash.Error $cmdExecContext.Result.Streams.Information = $hash.Information $cmdExecContext.Result.Streams.Warning = $hash.Warning $cmdExecContext.Result.Output = $hash.Output if ($cmdExecContext.Result.Errors.Count -gt 0) { $cmdExecContext.Result.Success = $false } else { $cmdExecContext.Result.Success = $true } } catch { $cmdExecContext.Complete = $true $cmdExecContext.Result.Success = $false $cmdExecContext.Result.Errors = $_.Exception.Message $cmdExecContext.Result.Streams.Error = $_.Exception.Message } } } else { $msg = "Command [$($cmdExecContext.Command.Name)] was not authorized for user [$($Message.From)]" $cmdExecContext.Complete = $true $cmdExecContext.Result.Errors += [CommandNotAuthorized]::New($msg) $cmdExecContext.Result.Success = $false $cmdExecContext.Result.Authorized = $false $this.TrackJob($cmdExecContext) return } $this.TrackJob($cmdExecContext) } # Add the command execution context to the job tracker # So the status and results of it can be checked later [void]TrackJob([CommandExecutionContext]$CommandContext) { if (-not $this._jobTracker.ContainsKey($CommandContext.Id)) { Write-Verbose -Message "[CommandExecutor:TrackJob] - Adding job [$($CommandContext.Id)] to tracker" $this._jobTracker.Add($CommandContext.Id, $CommandContext) } } # Receive any completed jobs from the job tracker [CommandExecutionContext[]]ReceiveJob() { $results = New-Object System.Collections.ArrayList if ($this._jobTracker.Count -ge 1) { $completedJobs = $this._jobTracker.GetEnumerator() | Where-Object {($_.Value.Complete -eq $true) -or ($_.Value.IsJob -and (($_.Value.Job.State -eq 'Completed') -or ($_.Value.Job.State -eq 'Failed')))} | Select-Object -ExpandProperty Value foreach ($cmdExecContext in $completedJobs) { # If the command was executed in a PS job, get the output # Builtin commands are NOT executed as jobs so their output # was already recorded in the [Result] property in the ExecuteCommand() method if ($cmdExecContext.IsJob) { if ($cmdExecContext.Job.State -eq 'Completed') { Write-Verbose -Message "[CommandExecutor:ReceiveJob] Job [$($cmdExecContext.Id)] is complete" $cmdExecContext.Complete = $true $cmdExecContext.Ended = (Get-Date).ToUniversalTime() # Capture all the streams $cmdExecContext.Result.Errors = $cmdExecContext.Job.ChildJobs[0].Error.ReadAll() $cmdExecContext.Result.Streams.Error = $cmdExecContext.Result.Errors $cmdExecContext.Result.Streams.Information = $cmdExecContext.Job.ChildJobs[0].Information.ReadAll() $cmdExecContext.Result.Streams.Verbose = $cmdExecContext.Job.ChildJobs[0].Verbose.ReadAll() $cmdExecContext.Result.Streams.Warning = $cmdExecContext.Job.ChildJobs[0].Warning.ReadAll() $cmdExecContext.Result.Output = $cmdExecContext.Job.ChildJobs[0].Output.ReadAll() # Determine if job had any terminating errors if ($cmdExecContext.Result.Streams.Error.Count -gt 0) { $cmdExecContext.Result.Success = $false } else { $cmdExecContext.Result.Success = $true } Write-Debug -Message "[CommandExecutor:ReceiveJob] Job results:`n$($cmdExecContext.Result | ConvertTo-Json)" # Clean up the job Remove-Job -Job $cmdExecContext.Job } elseIf ($cmdExecContext.Job.State -eq 'Failed') { $cmdExecContext.Complete = $true $cmdExecContext.Result.Success = $false } } # Send a success or fail reaction if ($this._bot.Configuration.AddCommandReactions) { if ($cmdExecContext.Result.Success) { $reaction = [ReactionType]::Success } else { $reaction = [ReactionType]::Failure } $this._bot.Backend.AddReaction($cmdExecContext.Message, $reaction) } # Add to history if ($cmdExecContext.Command.KeepHistory) { $this.AddToHistory($cmdExecContext) } Write-Verbose -Message "[CommandExecutor:ReceiveJob] Removing job [$($cmdExecContext.Id)] from tracker" $this._jobTracker.Remove($cmdExecContext.Id) # Remove the reaction specifying the command is in process if ($this._bot.Configuration.AddCommandReactions) { $this._bot.Backend.RemoveReaction($cmdExecContext.Message, [ReactionType]::Processing) } # Track number of commands executed if ($cmdExecContext.Result.Success) { $this.ExecutedCount++ } $cmdExecContext.Result.Duration = ($cmdExecContext.Ended - $cmdExecContext.Started) $results.Add($cmdExecContext) > $null } } return $results } # Add command result to history [void]AddToHistory([CommandExecutionContext]$CmdExecContext) { if ($this.History.Count -ge $this.HistoryToKeep) { $this.History.RemoveAt(0) > $null } $this.History.Add($CmdExecContext) } # 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 "[CommandExecutor:ValidateMandatoryParameters] Provided named parameters: $($ParsedCommand.NamedParameters.Keys | Format-List | Out-String)" foreach ($providedNamedParameter in $ParsedCommand.NamedParameters.Keys ) { Write-Verbose -Message "[CommandExecutor:ValidateMandatoryParameters] 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 } } # A scheduled message that the scheduler class will return when the time interval # has elapsed. The bot will treat this message as though it was returned from the # chat network like a normal message class ScheduledMessage { [string]$Id = (New-Guid).ToString() -Replace '-', '' [TimeInterval]$TimeInterval [int]$TimeValue [Message]$Message [bool]$Enabled = $true [double]$IntervalMS [int]$TimesExecuted = 0 [System.Diagnostics.Stopwatch]$Stopwatch [DateTime]$StartAfter = (Get-Date).ToUniversalTime() ScheduledMessage([TimeInterval]$Interval, [int]$TimeValue, [Message]$Message, [bool]$Enabled, [DateTime]$StartAfter) { $this.Init($Interval, $TimeValue, $Message, $Enabled, $StartAfter) } ScheduledMessage([TimeInterval]$Interval, [int]$TimeValue, [Message]$Message, [bool]$Enabled) { $this.Init($Interval, $TimeValue, $Message, $Enabled, (Get-Date).ToUniversalTime()) } ScheduledMessage([TimeInterval]$Interval, [int]$TimeValue, [Message]$Message, [DateTime]$StartAfter) { $this.Init($Interval, $TimeValue, $Message, $true, $StartAfter) } ScheduledMessage([TimeInterval]$Interval, [int]$TimeValue, [Message]$Message) { $this.Init($Interval, $TimeValue, $Message, $true, (Get-Date).ToUniversalTime()) } [void]Init([TimeInterval]$Interval, [int]$TimeValue, [Message]$Message, [bool]$Enabled, [DateTime]$StartAfter) { $this.TimeInterval = $Interval $this.TimeValue = $TimeValue $this.Message = $Message $this.Enabled = $Enabled $this.StartAfter = $StartAfter.ToUniversalTime() $this.Stopwatch = New-Object -TypeName System.Diagnostics.Stopwatch switch ($this.TimeInterval) { 'Days' { $this.IntervalMS = ($TimeValue * 86400000) break } 'Hours' { $this.IntervalMS = ($TimeValue * 3600000) break } 'Minutes' { $this.IntervalMS = ($TimeValue * 60000) break } 'Seconds' { $this.IntervalMS = ($TimeValue * 1000) break } } } [bool]HasElapsed() { if ($this.Stopwatch.ElapsedMilliseconds -gt $this.IntervalMS) { $now = (Get-Date).ToUniversalTime() if ($now -ge $this.StartAfter) { $this.TimesExecuted += 1 return $true } else{ return $false } } else { return $false } } [void]Enable() { $this.Enabled = $true $this.StartTimer() } [void]Disable() { $this.Enabled = $false $this.StopTimer() } [void]StartTimer() { $this.Stopwatch.Start() } [void]StopTimer() { $this.Stopwatch.Stop() } [void]ResetTimer() { $this.Stopwatch.Reset() $this.Stopwatch.Start() } [hashtable]ToHash() { return @{ Id = $this.Id TimeInterval = $this.TimeInterval.ToString() TimeValue = $this.TimeValue StartAfter = $This.StartAfter.ToUniversalTime() Message = @{ Id = $this.Message.Id Type = $this.Message.Type.ToString() Subtype = $this.Message.Subtype.ToString() Text = $this.Message.Text To = $this.Message.To From = $this.Message.From } Enabled = $this.Enabled IntervalMS = $this.IntervalMS } } } class Scheduler { [hashtable]$Schedules = @{} hidden [StorageProvider]$_Storage hidden [Logger]$_Logger Scheduler([StorageProvider]$Storage, [Logger]$Logger) { $this._Storage = $Storage $this._Logger = $Logger $this.Initialize() } [void]Initialize() { $this._Logger.Info([LogMessage]::new('[Scheduler:Initialize] Initializing')) $this.LoadState() } [void]LoadState() { $this._Logger.Verbose([LogMessage]::new('[Scheduler:LoadState] Loading scheduler state from storage')) if ($scheduleConfig = $this._Storage.GetConfig('schedules')) { foreach($key in $scheduleConfig.Keys) { $sched = $scheduleConfig[$key] $msg = [Message]::new() $msg.Id = $sched.Message.Id $msg.Text = $sched.Message.Text $msg.To = $sched.Message.To $msg.From = $sched.Message.From $msg.Type = $sched.Message.Type $msg.Subtype = $sched.Message.Subtype if (-not [string]::IsNullOrEmpty($sched.StartAfter)) { $newSchedule = [ScheduledMessage]::new($sched.TimeInterval, $sched.TimeValue, $msg, $sched.Enabled, $sched.StartAfter.ToUniversalTime()) } else { $newSchedule = [ScheduledMessage]::new($sched.TimeInterval, $sched.TimeValue, $msg, $sched.Enabled, (Get-Date).ToUniversalTime()) } $newSchedule.Id = $sched.Id $this.ScheduleMessage($newSchedule, $false) } $this.SaveState() } } [void]SaveState() { $this._Logger.Verbose([LogMessage]::new('[Scheduler:SaveState] Saving scheduler state to storage')) $schedulesToSave = @{} foreach ($schedule in $this.Schedules.GetEnumerator()) { $schedulesToSave.Add("sched_$($schedule.Name)", $schedule.Value.ToHash()) } $this._Storage.SaveConfig('schedules', $schedulesToSave) } [void]ScheduleMessage([ScheduledMessage]$ScheduledMessage) { $this.ScheduleMessage($ScheduledMessage, $true) } [void]ScheduleMessage([ScheduledMessage]$ScheduledMessage, [bool]$Save) { if (-not $this.Schedules.ContainsKey($ScheduledMessage.Id)) { $this._Logger.Info([LogMessage]::new("[Scheduler:ScheduleMessage] Scheduled message [$($ScheduledMessage.Id)]", $ScheduledMessage)) if ($ScheduledMessage.Enabled) { $ScheduledMessage.StartTimer() } $this.Schedules.Add($ScheduledMessage.Id, $ScheduledMessage) } else { $msg = "[Scheduler:ScheduleMessage] Id [$($ScheduledMessage.Id)] is already scheduled" $this._Logger.Info([LogMessage]::new([LogSeverity]::Error, $msg)) Write-Error -Message $msg } if ($Save) { $this.SaveState() } } [void]RemoveScheduledMessage([string]$Id) { if ($this.GetSchedule($Id)) { $this.Schedules.Remove($id) $this._Logger.Info([LogMessage]::new("[Scheduler:RemoveScheduledMessage] Scheduled message [$($_.Id)] removed")) $this.SaveState() } } [ScheduledMessage[]]ListSchedules() { $result = $this.Schedules.GetEnumerator() | Select-Object -ExpandProperty Value | Sort-Object -Property TimeValue -Descending return $result } [Message[]]GetTriggeredMessages() { $messages = $this.Schedules.GetEnumerator() | Foreach-Object { if ($_.Value.HasElapsed()) { $this._Logger.Info([LogMessage]::new("[Scheduler:GetTriggeredMessages] Timer reached on scheduled command [$($_.Value.Id)]")) $_.Value.ResetTimer() $newMsg = $_.Value.Message.Clone() $newMsg.Time = Get-Date $newMsg } } return $messages } [ScheduledMessage]GetSchedule([string]$Id) { if ($msg = $this.Schedules[$id]) { return $msg } else { $msg = "[Scheduler:GetSchedule] Unknown schedule Id [$Id]" $this._Logger.Info([LogMessage]::new([LogSeverity]::Error, $msg)) Write-Error -Message $msg return $null } } [ScheduledMessage]SetSchedule([ScheduledMessage]$ScheduledMessage) { $existingMessage = $this.GetSchedule($ScheduledMessage.Id) $existingMessage.Init($ScheduledMessage.TimeInterval, $ScheduledMessage.TimeValue, $ScheduledMessage.Message, $ScheduledMessage.Enabled, $ScheduledMessage.StartAfter) $this._Logger.Info([LogMessage]::new("[Scheduler:SetSchedule] Scheduled message [$($ScheduledMessage.Id)] modified", $existingMessage)) if ($existingMessage.Enabled) { $existingMessage.ResetTimer() } $this.SaveState() return $existingMessage } [ScheduledMessage]EnableSchedule([string]$Id) { if ($msg = $this.GetSchedule($Id)) { $this._Logger.Info([LogMessage]::new("[Scheduler:EnableSchedule] Enabled scheduled command [$($_.Id)] enabled")) $msg.Enable() $this.SaveState() return $msg } else { return $null } } [ScheduledMessage]DisableSchedule([string]$Id) { if ($msg = $this.GetSchedule($Id)) { $this._Logger.Info([LogMessage]::new("[Scheduler:DisableSchedule] Disabled scheduled command [$($_.Id)] enabled")) $msg.Disable() $this.SaveState() return $msg } else { return $null } } } 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 "$ModuleName\$($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() $cmd.Name = $command.Name # 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.Name = $metadata.CommandName } $cmd.Triggers += [Trigger]::new('Command', $cmd.Name) # Add any alternate command names as aliases to the command if ($metadata.Aliases) { $metadata.Aliases | Foreach-Object { $cmd.Aliases += $_ $cmd.Triggers += [Trigger]::new('Command', $_) } } # Add any permissions defined 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 to something other than 'Command' if ($metadata.TriggerType) { switch ($metadata.TriggerType) { 'Event' { $t = [Trigger]::new('Event', $command.Name) $t.Type = [TriggerType]::Event # The message type/subtype the command is intended to respond to if ($metadata.MessageType) { $t.MessageType = $metadata.MessageType } if ($metadata.MessageSubtype) { $t.MessageSubtype = $metadata.MessageSubtype } $cmd.Triggers += $t } 'Regex' { $t = [Trigger]::new([TriggerType]::Regex, $command.Name) $t.Type = [TriggerType]::Regex $t.Trigger = $metadata.Regex $cmd.Triggers += $t } } } } else { # No metadata defined so set the command name/trigger to the module function name $cmd.Name = $command.Name $cmd.Triggers += [Trigger]::new('Command', $cmd.Name) } $cmd.Description = $cmdHelp.Synopsis.Trim() $cmd.ManifestPath = $manifestPath $cmd.FunctionInfo = $command $cmd.Usage = ($cmdHelp.syntax | Out-String).Trim() # 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 [void]SendMessage([Response]$Response) { # Must be extended by the specific Backend implementation throw 'Implement me!' } # Add a reaction to an existing chat message [void]AddReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { # Must be extended by the specific Backend implementation throw 'Implement me!' } [void]AddReaction([Message]$Message, [ReactionType]$Type) { $this.AddReaction($Message, $Type, [string]::Empty) } # Add a reaction to an existing chat message [void]RemoveReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { # Must be extended by the specific Backend implementation throw 'Implement me!' } [void]RemoveReaction([Message]$Message, [ReactionType]$Type) { $this.RemoveReaction($Message, $Type, [string]::Empty) } # Receive a message [Message[]]ReceiveMessage() { # Must be extended by the specific Backend implementation throw 'Implement me!' } # Send a ping on the chat network [void]Ping() { # Only implement this method to send a message back # to the chat network to keep the connection open } # Get a user by their Id [Person]GetUser([string]$UserId) { # Must be extended by the specific Backend implementation throw 'Implement me!' } # Connect to the chat network [void]Connect() { $this.Connection.Connect() } # Disconnect from the chat network [void]Disconnect() { $this.Connection.Disconnect() } # Populate the list of users on the chat network [void]LoadUsers() { # Must be extended by the specific Backend implementation throw 'Implement me!' } # Populate the list of channel or rooms on the chat network [void]LoadRooms() { # Must be extended by the specific Backend implementation throw 'Implement me!' } # Get the bot identity Id [string]GetBotIdentity() { # Must be extended by the specific Backend implementation throw 'Implement me!' } # Resolve a user name to user id [string]UsernameToUserId([string]$Username) { # Must be extended by the specific Backend implementation throw 'Implement me!' } # Resolve a user ID to a username/nickname [string]UserIdToUsername([string]$UserId) { # Must be extended by the specific Backend implementation throw 'Implement me!' } } 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 [bool]$AddCommandReactions = $true } 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 [Scheduler]$Scheduler # 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) $this.Scheduler = [Scheduler]::new($this.Storage, $this._Logger) $this.GenerateCommandPrefixList() # Ugly hack alert! # Store the ConfigurationDirectory property in a script level variable # so the command class as access to it. $script:ConfigurationDirectory = $this.Configuration.ConfigurationDirectory # 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() # Get 0 or more scheduled jobs that need to be executed # and add to message queue $this.ProcessScheduledMessages() # Determine if message is for bot and handle as necessary $this.ProcessMessageQueue() # Receive any completed jobs and process them $this.ProcessCompletedJobs() 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 messages from the backend chat network [void]ReceiveMessage() { foreach ($msg in $this.Backend.ReceiveMessage()) { $this._Logger.Debug([LogMessage]::new('[Bot:ReceiveMessage] Received bot message from chat network. Adding to message queue.', $msg)) $this.MessageQueue.Enqueue($msg) } } # Receive any messages from the scheduler that had their timer elapse and should be executed [void]ProcessScheduledMessages() { foreach ($msg in $this.Scheduler.GetTriggeredMessages()) { $this._Logger.Debug([LogMessage]::new('[Bot:ProcessScheduledMessages] Received scheduled message from scheduler. Adding to message queue.', $msg)) $this.MessageQueue.Enqueue($msg) } } # Determine if message text is addressing the bot and should be # treated as a bot command [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(s) off queue and pass to 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) } $parsedCommand = [CommandParser]::Parse($Message) $this._Logger.Debug([LogMessage]::new('[Bot:HandleMessage] Parsed bot command', $parsedCommand)) # Match parsed command to a command in the plugin manager $pluginCmd = $this.PluginManager.MatchCommand($parsedCommand, $cmdSearch) if ($pluginCmd) { # Add the name of the plugin to the parsed command # if it wasn't fully qualified to begin with if ([string]::IsNullOrEmpty($parsedCommand.Plugin)) { $parsedCommand.Plugin = $pluginCmd.Plugin.Name } # 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) } } $this.Executor.ExecuteCommand($PluginCmd, $ParsedCommand, $Message) } else { if ($isBotCommand) { $msg = "No command found matching [$($Message.Text)]" $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 = [Response]::new() $response.MessageFrom = $Message.From $response.To = $Message.To $response.Severity = [Severity]::Warning $response.Data = New-PoshBotCardResponse -Type Warning -Text $msg $this.SendMessage($response) } } } } # Get completed jobs, determine success/error, then return response to backend [void]ProcessCompletedJobs() { $completedJobs = $this.Executor.ReceiveJob() $count = $completedJobs.Count if ($count -ge 1) { $this._Logger.Info([LogMessage]::new("[Bot:ProcessCompletedJobs] Processing [$count] completed jobs")) } foreach ($cmdExecContext in $completedJobs) { $response = [Response]::new() $response.MessageFrom = $cmdExecContext.Message.From $response.To = $cmdExecContext.Message.To if (-not $cmdExecContext.Result.Success) { # Was the command authorized? if (-not $cmdExecContext.Result.Authorized) { $response.Severity = [Severity]::Warning $response.Data = New-PoshBotCardResponse -Type Warning -Text "You do not have authorization to run command [$($cmdExecContext.Command.Name)] :(" -Title 'Command Unauthorized' } else { # TODO # Handle this better $response.Severity = [Severity]::Error if ($cmdExecContext.Result.Errors.Count -gt 0) { $response.Data = $cmdExecContext.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' $response.Data += $cmdExecContext.Result.Errors } } } else { foreach ($resultOutput in $cmdExecContext.Result.Output) { if ($null -ne $resultOutput) { if ($this._IsCustomResponse($resultOutput)) { $response.Data += $resultOutput } else { # If the response is a simple type, just display it as a string # otherwise we need remove auto-generated properties that show up # from deserialized objects if ($this._IsPrimitiveType($resultOutput)) { $response.Text += $resultOutput.ToString().Trim() } else { $deserializedProps = 'PSComputerName', 'PSShowComputerName', 'PSSourceJobInstanceId', 'RunspaceId' $resultText = $resultOutput | Select-Object -Property * -ExcludeProperty $deserializedProps $response.Text += ($resultText | Format-List -Property * | Out-String).Trim() } } } } } # Send response back to user in private (DM) channel if this command # is marked to devert responses if ($this.Configuration.SendCommandResponseToPrivate -contains $cmdExecContext.FullyQualifiedCommandName) { $this._Logger.Info([LogMessage]::new("[Bot:HandleMessage] Deverting response from command [$($cmdExecContext.FullyQualifiedCommandName)] to private channel")) $response.To = "@$($this.RoleManager.ResolveUserToId($cmdExecContext.Message.From))" } $this.SendMessage($response) } } # 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 } # Create complete list of command prefixes so we can quickly # evaluate messages from the chat network and determine if # they are bot commands [void]GenerateCommandPrefixList() { $this._PossibleCommandPrefixes.Add($this.Configuration.CommandPrefix) foreach ($alternatePrefix in $this.Configuration.AlternateCommandPrefixes) { $this._PossibleCommandPrefixes.Add($alternatePrefix) > $null foreach ($seperator in ($this.Configuration.AlternateCommandPrefixSeperators)) { $prefixPlusSeperator = "$alternatePrefix$seperator" $this._PossibleCommandPrefixes.Add($prefixPlusSeperator) > $null } } } # Send the response to the backend to execute [void]SendMessage([Response]$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 } # Determine if response from command is custom and the output should be formatted hidden [bool]_IsCustomResponse([object]$Response) { $isCustom = (($Response.PSObject.TypeNames[0] -eq 'PoshBot.Text.Response') -or ($Response.PSObject.TypeNames[0] -eq 'PoshBot.Card.Response') -or ($Response.PSObject.TypeNames[0] -eq 'PoshBot.File.Upload') -or ($Response.PSObject.TypeNames[0] -eq 'Deserialized.PoshBot.Text.Response') -or ($Response.PSObject.TypeNames[0] -eq 'Deserialized.PoshBot.Card.Response') -or ($Response.PSObject.TypeNames[0] -eq 'Deserialized.PoshBot.File.Upload')) if ($isCustom) { $this._Logger.Debug([LogMessage]::new("[Bot:_IsCustomResponse] Detected custom response [$($Response.PSObject.TypeNames[0])] from command")) } return $isCustom } # Test if an object is a primitive data type hidden [bool] _IsPrimitiveType([object]$Item) { $primitives = @('Byte', 'SByte', 'Int16', 'Int32', 'Int64', 'UInt16', 'UInt32', 'UInt64', 'Decimal', 'Single', 'Double', 'TimeSpan', 'DateTime', 'ProgressRecord', 'Char', 'String', 'XmlDocument', 'SecureString', 'Boolean', 'Guid', 'Uri', 'Version' ) return ($Item.GetType().Name -in $primitives) } } 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. .PARAMETER LiteralPath Specifies the path(s) to the current location of the file(s). Unlike the Path parameter, the value of LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards. If the path includes escape characters, enclose it in single quotation marks. Single quotation marks tell PowerShell not to interpret any characters as escape sequences. .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 AddCommandReactions : True 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(DefaultParameterSetName = 'Path')] param( [parameter( Mandatory, ParameterSetName = 'Path', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [SupportsWildcards()] [string[]]$Path, [parameter( Mandatory, ParameterSetName = 'LiteralPath', Position = 0, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [Alias('PSPath')] [string[]]$LiteralPath ) process { # Resolve path(s) if ($PSCmdlet.ParameterSetName -eq 'Path') { $paths = Resolve-Path -Path $Path | Select-Object -ExpandProperty Path } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { $paths = Resolve-Path -LiteralPath $LiteralPath | Select-Object -ExpandProperty Path } foreach ($item in $paths) { if (Test-Path $item) { if ( (Get-Item -Path $item).Extension -eq '.psd1') { 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 { Throw 'Path must be to a valid .psd1 file' } } else { Write-Error -Message "Path [$item] is not valid." } } } } Export-ModuleMember -Function 'Get-PoshBotConfiguration' function Get-PoshBotStatefulData { <# .SYNOPSIS Get stateful data previously exported from a PoshBot command .DESCRIPTION Get stateful data previously exported from a PoshBot command Reads data from the PoshBot ConfigurationDirectory. .PARAMETER Name If specified, retrieve only this property from the stateful data .PARAMETER ValueOnly If specified, return only the value of the specified property Name .PARAMETER Scope Get stateful data from this scope: Module: Data scoped to this plugin Global: Data available to any Poshbot plugin .EXAMPLE $ModuleData = Get-PoshBotStatefulData Get all stateful data for the PoshBot plugin this runs from .EXAMPLE $Something = Get-PoshBotStatefulData -Name 'Something' -ValueOnly -Scope Global Set $Something to the value of the 'Something' property from Poshbot's global stateful data .LINK Set-PoshBotStatefulData .LINK Remove-PoshBotStatefulData .LINK Start-PoshBot #> [cmdletbinding()] param( [string]$Name = '*', [switch]$ValueOnly, [validateset('Global','Module')] [string]$Scope = 'Module' ) process { if($Scope -eq 'Module') { $FileName = "$($PoshBotContext.Plugin).state" } else { $FileName = "PoshbotGlobal.state" } $Path = Join-Path $PoshBotContext.ConfigurationDirectory $FileName if(-not (Test-Path $Path)) { Write-Verbose "Requested stateful data file not found: [$Path]" return } Write-Verbose "Getting stateful data from [$Path]" $Output = Import-Clixml -Path $Path | Select-Object -Property $Name if($ValueOnly) { $Output = $Output.${Name} } $Output } } Export-ModuleMember -Function 'Get-PoshBotStatefulData' 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. .PARAMETER AddCommandReactions Add reactions to a chat message indicating the command is being executed, has succeeded, or failed. .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 AddCommandReactions : True 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 AddCommandReactions : True 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, [bool]$AddCommandReactions = $true ) 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.AddCommandReactions = $AddCommandReactions $config } Export-ModuleMember -Function 'New-PoshBotConfiguration' function New-PoshBotFileUpload { <# .SYNOPSIS Tells PoshBot to upload a file to the chat network. .DESCRIPTION Returns a custom object back to PoshBot telling it to upload the given file to the chat network. The custom object can also tell PoshBot to redirect the file upload 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 Path The path(s) to one or more files to upload. Wildcards are permitted. .PARAMETER LiteralPath Specifies the path(s) to the current location of the file(s). Unlike the Path parameter, the value of LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards. If the path includes escape characters, enclose it in single quotation marks. Single quotation marks tell PowerShell not to interpret any characters as escape sequences. .PARAMETER Title The title for the uploaded file. .PARAMETER DM Tell PoshBot to redirect the file upload to a DM channel. .EXAMPLE function Do-Stuff { [cmdletbinding()] param() $myObj = [pscustomobject]@{ value1 = 'foo' value2 = 'bar' } $csv = Join-Path -Path $env:TEMP -ChildPath "$((New-Guid).ToString()).csv" $myObj | Export-Csv -Path $csv -NoTypeInformation New-PoshBotFileUpload -Path $csv } Export a CSV file and tell PoshBot to upload the file back to the channel that initiated this command. .EXAMPLE function Get-SecretPlan { [cmdletbinding()] param() $myObj = [pscustomobject]@{ Title = 'Secret moon base' Description = 'Plans for secret base on the dark side of the moon' } $csv = Join-Path -Path $env:TEMP -ChildPath "$((New-Guid).ToString()).csv" $myObj | Export-Csv -Path $csv -NoTypeInformation New-PoshBotFileUpload -Path $csv -Title 'YourEyesOnly.csv' -DM } Export a CSV file and tell PoshBot to upload the file back to a DM channel with the calling user. .INPUTS String .OUTPUTS PSCustomObject .LINK New-PoshBotCardResponse .LINK New-PoshBotTextResponse #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Scope='Function', Target='*')] [cmdletbinding(DefaultParameterSetName = 'Path')] param( [parameter( Mandatory, ParameterSetName = 'Path', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [SupportsWildcards()] [string[]]$Path, [parameter( Mandatory, ParameterSetName = 'LiteralPath', Position = 0, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [Alias('PSPath')] [string[]]$LiteralPath, [string]$Title = [string]::Empty, [switch]$DM ) process { # Resolve path(s) if ($PSCmdlet.ParameterSetName -eq "Path") { $paths = Resolve-Path -Path $Path | Select-Object -ExpandProperty Path } elseif ($PSCmdlet.ParameterSetName -eq "LiteralPath") { $paths = Resolve-Path -LiteralPath $LiteralPath | Select-Object -ExpandProperty Path } foreach ($item in $paths) { [pscustomobject][ordered]@{ PSTypeName = 'PoshBot.File.Upload' Path = $item Title = $Title DM = ($PSBoundParameters.ContainsKey('DM') -and $DM) } } } } Export-ModuleMember -Function 'New-PoshBotFileUpload' 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 LiteralPath Specifies the path(s) to the current location of the file(s). Unlike the Path parameter, the value of LiteralPath is used exactly as it is typed. No characters are interpreted as wildcards. If the path includes escape characters, enclose it in single quotation marks. Single quotation marks tell PowerShell not to interpret any characters as escape sequences. .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( Mandatory, ParameterSetName = 'Path', Position = 0, ValueFromPipeline, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [SupportsWildcards()] [string[]]$Path, [parameter( Mandatory, ParameterSetName = 'LiteralPath', Position = 0, ValueFromPipelineByPropertyName )] [ValidateNotNullOrEmpty()] [Alias('PSPath')] [string[]]$LiteralPath, [parameter( Mandatory, ParameterSetName = 'config', ValueFromPipeline, ValueFromPipelineByPropertyName )] [BotConfiguration[]]$Configuration, [parameter(Mandatory)] [Backend]$Backend ) begin { $here = $PSScriptRoot } process { if ($PSCmdlet.ParameterSetName -eq 'path' -or $PSCmdlet.ParameterSetName -eq 'LiteralPath') { # Resolve path(s) if ($PSCmdlet.ParameterSetName -eq 'Path') { $paths = Resolve-Path -Path $Path | Select-Object -ExpandProperty Path } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { $paths = Resolve-Path -LiteralPath $LiteralPath | Select-Object -ExpandProperty Path } $Configuration = @() foreach ($item in $paths) { if (Test-Path $item) { if ( (Get-Item -Path $item).Extension -eq '.psd1') { $Configuration += Get-PoshBotConfiguration -Path $item } else { Throw 'Path must be to a valid .psd1 file' } } else { Write-Error -Message "Path [$item] is not valid." } } } 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 AsCode Format the text in a code block if the backend supports it. .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]$AsCode, [switch]$DM ) process { foreach ($item in $text) { [pscustomobject][ordered]@{ PSTypeName = 'PoshBot.Text.Response' Text = $item AsCode = $PSBoundParameters.ContainsKey('AsCode') DM = $PSBoundParameters.ContainsKey('DM') } } } } Export-ModuleMember -Function 'New-PoshBotTextResponse' function Remove-PoshBotStatefulData { <# .SYNOPSIS Remove existing stateful data .DESCRIPTION Remove existing stateful data .PARAMETER Name Property to remove from the stateful data file .PARAMETER Scope Sets the scope of stateful data to remove: Module: Remove stateful data from the current module's data Global: Remove stateful data from the global PoshBot data .PARAMETER Depth Specifies how many levels of contained objects are included in the XML representation. The default value is 2 .EXAMPLE PS C:\> Remove-PoshBotStatefulData -Name 'ToUse' Removes the 'ToUse' property from stateful data for the PoshBot plugin you are currently running this from. .EXAMPLE PS C:\> Remove-PoshBotStatefulData -Name 'Something' -Scope Global Removes the 'Something' property from PoshBot's global stateful data .LINK Get-PoshBotStatefulData .LINK Set-PoshBotStatefulData .LINK Start-PoshBot #> [cmdletbinding(SupportsShouldProcess)] param( [parameter(Mandatory)] [string[]]$Name, [validateset('Global','Module')] [string]$Scope = 'Module', [int]$Depth = 2 ) process { if($Scope -eq 'Module') { $FileName = "$($PoshBotContext.Plugin).state" } else { $FileName = "PoshbotGlobal.state" } $Path = Join-Path $PoshBotContext.ConfigurationDirectory $FileName if(-not (Test-Path $Path)) { return } else { $ToWrite = Import-Clixml -Path $Path | Select-Object * -ExcludeProperty $Name } if ($PSCmdlet.ShouldProcess($Name, 'Remove stateful data')) { Export-Clixml -Path $Path -InputObject $ToWrite -Depth $Depth -Force Write-Verbose -Message "Stateful data [$Name] removed from [$Path]" } } } Export-ModuleMember -Function 'Remove-PoshBotStatefulData' 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 Set-PoshBotStatefulData { <# .SYNOPSIS Save stateful data to use in another PoshBot command .DESCRIPTION Save stateful data to use in another PoshBot command Stores data in clixml format, in the PoshBot ConfigurationDirectory. If <Name> property exists in current stateful data file, it is overwritten .PARAMETER Name Property to add to the stateful data file .PARAMETER Value Value to set for the Name property in the stateful data file .PARAMETER Scope Sets the scope of stateful data to set: Module: Allow only this plugin to access the stateful data you save Global: Allow any plugin to access the stateful data you save .PARAMETER Depth Specifies how many levels of contained objects are included in the XML representation. The default value is 2 .EXAMPLE PS C:\> Set-PoshBotStatefulData -Name 'ToUse' -Value 'Later' Adds a 'ToUse' property to the stateful data for the PoshBot plugin you are currently running this from. .EXAMPLE PS C:\> $Anything | Set-PoshBotStatefulData -Name 'Something' -Scope Global Adds a 'Something' property to PoshBot's global stateful data, with the value of $Anything .LINK Get-PoshBotStatefulData .LINK Remove-PoshBotStatefulData .LINK Start-PoshBot #> [cmdletbinding(SupportsShouldProcess)] param( [parameter(Mandatory)] [string]$Name, [parameter(ValueFromPipeline, Mandatory)] [object[]]$Value, [validateset('Global','Module')] [string]$Scope = 'Module', [int]$Depth = 2 ) end { if ($Value.Count -eq 1) { $Value = $Value[0] } if($Scope -eq 'Module') { $FileName = "$($PoshBotContext.Plugin).state" } else { $FileName = "PoshbotGlobal.state" } $Path = Join-Path $PoshBotContext.ConfigurationDirectory $FileName if(-not (Test-Path $Path)) { $ToWrite = [pscustomobject]@{ $Name = $Value } } else { $Existing = Import-Clixml -Path $Path # TODO: Consider handling for -Force? If($Existing.PSObject.Properties.Name -contains $Name) { Write-Verbose "Overwriting [$Name]`nCurrent value: [$($Existing.$Name | Out-String)])`nNew Value: [$($Value | Out-String)]" } Add-Member -InputObject $Existing -MemberType NoteProperty -Name $Name -Value $Value -Force $ToWrite = $Existing } if ($PSCmdlet.ShouldProcess($Name, 'Set stateful data')) { Export-Clixml -Path $Path -InputObject $ToWrite -Depth $Depth -Force Write-Verbose -Message "Stateful data [$Name] saved to [$Path]" } } } Export-ModuleMember -Function 'Set-PoshBotStatefulData' 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 # 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 } # Connect to Slack [void]Connect() { $this.Connection.Connect() $this.BotId = $this.GetBotIdentity() $this.LoadUsers() $this.LoadRooms() } # Receive a message from the websocket [Message[]]ReceiveMessage() { $messages = New-Object -TypeName System.Collections.ArrayList try { # Read the output stream from the receive job and get any messages since our last read $jsonResult = $this.Connection.ReadReceiveJob() 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) $slackMessages = @($jsonResult | ConvertFrom-Json) foreach ($slackMessage in $slackMessages) { # 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 } if ($slackMessage.ts) { $unixEpoch = [datetime]'1970-01-01' $msg.Time = $unixEpoch.AddSeconds($slackMessage.ts) } else { $msg.Time = (Get-Date).ToUniversalTime() } # 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 } } # ** Important safety tip, don't cross the streams ** # Only return messages that didn't come from the bot # else we'd cause a feedback loop with the bot processing # it's own responses if (-not $this.MsgFromBot($msg.From)) { $messages.Add($msg) > $null } } } } } catch { Write-Error $_ } return $messages } # 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() } # Send a message back to Slack [void]SendMessage([Response]$Response) { # Process any custom responses foreach ($customResponse in $Response.Data) { [string]$sendTo = $Response.To if ($customResponse.DM) { $sendTo = "@$($this.UserIdToUsername($Response.MessageFrom))" } switch -Regex ($customResponse.PSObject.TypeNames[0]) { '(.*?)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 } break } '(.*?)PoshBot\.Text\.Response' { $chunks = $this._ChunkString($customResponse.Text) foreach ($chunk in $chunks) { if ($customResponse.AsCode) { $t = '```' + $chunk + '```' } else { $t = $chunk } $slackResponse = Send-SlackMessage -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Channel $sendTo -Text $t -Verbose:$false -AsUser } break } '(.*?)PoshBot\.File\.Upload' { $uploadParams = @{ Token = $this.Connection.Config.Credential.GetNetworkCredential().Password Channel = $sendTo Path = $customResponse.Path } if (-not [string]::IsNullOrEmpty($customResponse.Title)) { $uploadParams.Title = $customResponse.Title } else { $uploadParams.Title = Split-Path -Path $customResponse.Path -Leaf } Send-SlackFile @uploadParams -Verbose:$false Remove-Item -LiteralPath $customResponse.Path -Force break } } } 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 } } } # Add a reaction to an existing chat message [void]AddReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { if ($Message.RawMessage.ts) { if ($Type -eq [ReactionType]::Custom) { $emoji = $Reaction } else { $emoji = $this._ResolveEmoji($Type) } $body = @{ name = $emoji channel = $Message.To timestamp = $Message.RawMessage.ts } $resp = Send-SlackApi -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Method 'reactions.add' -Body $body -Verbose:$false if (-not $resp.ok) { Write-Error $resp } } } # Remove a reaction from an existing chat message [void]RemoveReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { if ($Message.RawMessage.ts) { if ($Type -eq [ReactionType]::Custom) { $emoji = $Reaction } else { $emoji = $this._ResolveEmoji($Type) } $body = @{ name = $emoji channel = $Message.To timestamp = $Message.RawMessage.ts } $resp = Send-SlackApi -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Method 'reactions.remove' -Body $body -Verbose:$false if (-not $resp.ok) { Write-Error $resp } } } # Resolve a channel name to an Id [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 } # Populate the list of users the Slack team [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) } } } # Populate the list of channels in the Slack team [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) } } } # Get the bot identity Id [string]GetBotIdentity() { return $this.Connection.LoginData.self.id } # Determine if incoming message was from the bot [bool]MsgFromBot([string]$From) { return $this.BotId -eq $From } # Get a user by their Id [SlackPerson]GetUser([string]$UserId) { $user = $this.Users[$UserId] if ($user) { return $user } else { $this.LoadUsers() return $this.Users[$UserId] } } # Get a user Id by their name [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 } } # Get a user name by their Id [string]UserIdToUsername([string]$UserId) { if ($this.Users.ContainsKey($UserId)) { return $this.Users[$UserId].Nickname } else { $this.LoadUsers() return $this.Users[$UserId].Nickname } } # Remove extra characters that Slack decorates urls with hidden [string] _SanitizeURIs([string]$Text) { $sanitizedText = $Text -replace '<([^\|>]+)\|([^\|>]+)>', '$2' $sanitizedText = $sanitizedText -replace '<(http([^>]+))>', '$1' return $sanitizedText } # Break apart a string by number of characters hidden [System.Collections.ArrayList] _ChunkString([string]$Text) { return [regex]::Split($Text, "(?<=\G.{$($this.MaxMessageLength)})", [System.Text.RegularExpressions.RegexOptions]::Singleline) } # Resolve a reaction type to an emoji hidden [string]_ResolveEmoji([ReactionType]$Type) { $emoji = [string]::Empty Switch ($Type) { 'Success' { return 'white_check_mark' } 'Failure' { return 'exclamation' } 'Processing' { return 'gear' } } return $emoji } } function New-PoshBotSlackBackend { <# .SYNOPSIS Create a new instance of a Slack backend .DESCRIPTION Create a new instance of a Slack backend .PARAMETER Configuration The hashtable containing backend-specific properties on how to create the Slack backend instance. .EXAMPLE PS C:\> $backendConfig = @{Name = 'SlackBackend'; Token = '<SLACK-API-TOKEN>'} PS C:\> $backend = New-PoshBotSlackBackend -Configuration $backendConfig Create a Slack backend using the specified API token .INPUTS Hashtable .OUTPUTS SlackBackend #> [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 [pscustomobject]$LoginData [string]$UserName [string]$Domain [string]$WebSocketUrl [bool]$Connected [object]$ReceiveJob = $null SlackConnection() { $this.WebSocket = New-Object System.Net.WebSockets.ClientWebSocket $this.WebSocket.Options.KeepAliveInterval = 5 } # Connect to Slack and start receiving messages [void]Connect() { if ($null -eq $this.ReceiveJob -or $this.ReceiveJob.State -ne 'Running') { $this.RtmConnect() $this.StartReceiveJob() } } # Log in to Slack with the bot token and get a URL to connect to via websockets [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 $_ } } # Setup the websocket receive job [void]StartReceiveJob() { $recv = { [cmdletbinding()] param( [parameter(mandatory)] $url ) # Connect to websocket Write-Verbose "[SlackBackend:ReceiveJob] Connecting to websocket at [$($url)]" [System.Net.WebSockets.ClientWebSocket]$webSocket = New-Object System.Net.WebSockets.ClientWebSocket $cts = New-Object System.Threading.CancellationTokenSource $task = $webSocket.ConnectAsync($url, $cts.Token) do { Start-Sleep -Milliseconds 100 } until ($task.IsCompleted) # Receive messages and put on output stream so the backend can read them $buffer = (New-Object System.Byte[] 4096) $ct = New-Object System.Threading.CancellationToken $taskResult = $null while ($webSocket.State -eq [System.Net.WebSockets.WebSocketState]::Open) { do { $taskResult = $webSocket.ReceiveAsync($buffer, $ct) while (-not $taskResult.IsCompleted) { Start-Sleep -Milliseconds 100 } } until ( $taskResult.Result.Count -lt 4096 ) $jsonResult = [System.Text.Encoding]::UTF8.GetString($buffer, 0, $taskResult.Result.Count) if (-not [string]::IsNullOrEmpty($jsonResult)) { $jsonResult } } } try { $this.ReceiveJob = Start-Job -Name ReceiveRtmMessages -ScriptBlock $recv -ArgumentList $this.WebSocketUrl -ErrorAction Stop -Verbose $this.Connected = $true $this.Status = [ConnectionStatus]::Connected Write-Verbose "[SlackConnection:StartReceiveJob] Started websocket receive job [$($this.ReceiveJob.Id)]" } catch { throw $_ } } # Read all available data from the job [string]ReadReceiveJob() { if ($this.ReceiveJob.HasMoreData) { return $this.ReceiveJob.ChildJobs[0].Output.ReadAll() } else { return $null } } # Stop the receive job [void]Disconnect([System.Net.WebSockets.WebSocketCloseStatus]$Reason = [System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure) { Write-Verbose -Message '[SlackConnection:Disconnect] Closing websocket' $this.ReceiveJob | Stop-Job -Confirm:$false $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 } |