PoshBot.Spark.Backend.psm1
using module PoshBot [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '', Scope='Class', Target='*')] class SparkBackend : Backend { # The types of message that we care about from Spark # All othere will be ignored [string[]]$MessageTypes = @("conversation.activity") SparkBackend ([string]$Token) { Import-Module PSSpark -Verbose:$false -ErrorAction Stop $config = [ConnectionConfig]::new() $secToken = $Token | ConvertTo-SecureString -AsPlainText -Force $config.Credential = New-Object System.Management.Automation.PSCredential('asdf', $secToken) $conn = [SparkConnection]::New() $conn.Config = $config $this.Connection = $conn } # Connect to Spark [void]Connect() { $this.LogInfo('Connecting to backend') $this.LogInfo('Listening for the following message types. All others will be ignored', $this.MessageTypes) $this.Connection.Connect() $this.BotId = $this.GetBotIdentity() } # 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 "[SparkBackend:ReceiveMessage] Received `n$jsonResult" $this.LogDebug('Received message', $jsonResult) $sparkMessages = @($jsonResult | ConvertFrom-Json) foreach($sparkMessage in $sparkMessages) { # We only care about certain message types from Spark if($sparkMessage.data.eventType -in $this.MessageTypes) { $msg = [Message]::new() # Set the message type and optionally the subtype switch($sparkMessage.data.eventType) { 'conversation.activity' { $msg.Type = [MessageType]::Message $sparkMessage = Get-SparkMessage -MessageID $sparkMessage.data.activity.id } } $this.LogDebug("Message type is [$($msg.Type)`:$($msg.Subtype)]") if($sparkMessage.Text) { $msg.Text = $sparkMessage.Text } if($sparkMessage.RoomID) { $msg.To = $sparkMessage.RoomID } if($sparkMessage.UserID) { $msg.From = $sparkMessage.UserID } $processed = $this._ProcessMentions($msg.Text) $msg.Text = $processed if ($msg.From) { $msg.FromName = $this.UserIdToUsername($msg.From) } # Resolve channel name if ($msg.To -and $msg.To -notmatch '^D') { $msg.ToName = $this.ChannelIdToName($msg.To) } if($sparkMessage.Created) { $msg.Time = $sparkMessage.Created } else { $msg.Time = (Get-Date).ToUniversalTime() } # ** 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 } } else { $this.LogDebug("Message type is [$($sparkMessage.Type)]. Ignoring") } } } } catch { Write-Error $_ } return $messages } # Send a Slack ping [void]Ping() { } # Send a message back to Slack [void]SendMessage([Response]$Response) { if ($Response.Data.Text.Count -gt 0) { foreach ($t in $Response.Data.Text) { $t = "```````n" + $t $this.LogDebug("Sending response back to Spark channel [$($Response.To)]", $t) Send-SparkMessage -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -RoomID $Response.To -MarkdownText $t -Verbose:$false } } } # Add a reaction to an existing chat message [void]AddReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { } # Remove a reaction from an existing chat message [void]RemoveReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { } # Resolve a channel name to an Id [string]ResolveChannelId([string]$ChannelName) { if ($ChannelName -match '^#') { $ChannelName = $ChannelName.TrimStart('#') } $channelId = Get-SparkRoom -Name $ChannelName | Select-Object -ExpandProperty RoomID $this.LogDebug("Resolved channel [$ChannelName] to [$channelId]") return $channelId } # Populate the list of users the Slack team [void]LoadUsers() { } # Populate the list of channels in the Slack team [void]LoadRooms() { } # Get the bot identity Id [string]GetBotIdentity() { $id = $this.Connection.LoginData.userId $id = Get-SparkUser -UserID $id | Select-Object -ExpandProperty UserID $this.LogVerbose("Bot identity is [$id]") return $id } # Determine if incoming message was from the bot [bool]MsgFromBot([string]$From) { $frombot = ($this.BotId -eq $From) if ($fromBot) { $this.LogDebug("Message is from bot [From: $From == Bot: $($this.BotId)]. Ignoring") } else { $this.LogDebug("Message is not from bot [From: $From <> Bot: $($this.BotId)]") } return $fromBot } # Get a user by their Id [SparkPerson]GetUser([string]$UserId) { $user = Get-SparkUser -UserID $UserId $person = [SparkPerson]::new() $person.Id = $user.UserID $person.Nickname = $user.NickName $person.FullName = $user.Name $person.FirstName = $user.FirstName $person.LastName = $user.LastName $person.Email = $user.Email $person.Type = $user.Type $person.Status = $user.Status $person.Avatar = $user.Avatar $person.LastActivity = $user.LastActivity $person.Created = $user.Created return $person } # Get a user Id by their name [string]UsernameToUserId([string]$Username) { $Username = $Username.TrimStart('@') $id = Get-SparkUser -Name $Username | Select-Object -ExpandProperty UserID return $id } # Get a user name by their Id [string]UserIdToUsername([string]$UserId) { $name = $null $name = Get-SparkUser -UserID $UserId | Select-Object -ExpandProperty Name return $name } # Get the channel name by Id [string]ChannelIdToName([string]$ChannelId) { $name = $null $name = Get-SparkRoom -RoomID $ChannelId | Select-Object -ExpandProperty Name return $name } # 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) { $chunks = [regex]::Split($Text, "(?<=\G.{$($this.MaxMessageLength)})", [System.Text.RegularExpressions.RegexOptions]::Singleline) $this.LogDebug("Split response into [$($chunks.Count)] chunks") return $chunks } # Resolve a reaction type to an emoji hidden [string]_ResolveEmoji([ReactionType]$Type) { $emoji = [string]::Empty Switch ($Type) { 'Success' { return 'U+2714' } 'Failure' { return 'U+2757' } 'Processing' { return 'U+2699' } 'Warning' { return 'U+26A0' } 'ApprovalNeeded' { return 'U+1F510' } 'Cancelled' { return 'U+1F6AB' } 'Denied' { return 'U+274C' } } return $emoji } hidden [string]_UnicodeToString([string]$UnicodeChars) { $UnicodeChars = $UnicodeChars -replace 'U\+', ''; $UnicodeArray = @(); foreach ($UnicodeChar in $UnicodeChars.Split(' ')) { $Int = [System.Convert]::ToInt32($UnicodeChar, 16); $UnicodeArray += [System.Char]::ConvertFromUtf32($Int); } return $UnicodeArray -join [String]::Empty; } # Strips bot username from text hidden [string]_ProcessMentions([string]$Text) { $processed = ($Text -split "^sparky\s")[1] return $processed } } class SparkConnection : Connection { [System.Net.WebSockets.ClientWebSocket]$WebSocket [pscustomobject]$LoginData [string]$UserName [string]$WebSocketUrl [bool]$Connected [object]$ReceiveJob = $null SparkConnection() { $this.WebSocket = New-Object System.Net.WebSockets.ClientWebSocket } # Connect to Spark and start receiving messages [void]Connect() { if($null -eq $this.ReceiveJob -or $this.ReceiveJob.State -ne 'Running') { $this.LogDebug('Connecting to Spark Real Time API') $this.RtmConnect() $this.StartReceiveJob() } else { $this.LogDebug([LogSeverity]::Warning, 'Receive job is already running') } } # Log in to Spark with the bot token and get a URL to connect to via websockets [void]RtmConnect() { $token = $this.Config.Credential.GetNetworkCredential().Password $url = New-SparkWebSocket -Token $token | Select-Object -ExpandProperty url $headers = @{ "Authorization" = "Bearer $token" } try { $r = Invoke-RestMethod -Uri $url -Method Get -Headers $headers -Verbose:$false $this.LoginData = $r if($r) { $this.LogInfo('Successfully authenticated to Spark Real Time API') $this.WebSocketUrl = $r.webSocketUrl $this.UserName = Get-SparkUser -UserID $r.userId | Select-Object -ExpandProperty Name } else { throw $r } } catch { $this.LogInfo([LogSeverity]::Error, 'Error connecting to Spark Real Time API', [ExceptionFormatter]::Summarize($_)) } } # Setup the websocket receive job [void]StartReceiveJob() { $recv = { [cmdletbinding()] param( [parameter(mandatory)] $url, [parameter(mandatory)] $token ) # Connect to websocket Write-Verbose "[SparkBackend:ReceiveJob] Connecting to websocket at [$($url)]" [System.Net.WebSockets.ClientWebSocket]$webSocket = New-Object System.Net.WebSockets.ClientWebSocket $ct = New-Object System.Threading.CancellationToken $task = $webSocket.ConnectAsync($url, $ct) $buffer = (New-Object System.Byte[] 4096) $taskResult = $null while(-not $task.IsCompleted) { Start-Sleep -Milliseconds 100 } $Body = @{ id = [guid]::NewGuid().guid type = "authorization" data = @{ token = "Bearer $Token" } } | ConvertTo-Json $Array = @() $Body.ToCharArray() | ForEach { $Array += [byte]$_ } $Body = New-Object System.ArraySegment[byte] -ArgumentList @(,$Array) $Conn = $webSocket.SendAsync($Body, [System.Net.WebSockets.WebSocketMessageType]::Text, [System.Boolean]::TrueString, $ct) while(-not $Conn.IsCompleted) { Start-Sleep -Milliseconds 100 } # Receive messages and put on output stream so the backend can read them 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 } } $socketStatus = [pscustomobject]@{ State = $webSocket.State CloseStatus = $webSocket.CloseStatus CloseStatusDescription = $webSocket.CloseStatusDescription } $socketStatusStr = ($socketStatus | Format-List | Out-String).Trim() Write-Warning -Message "Websocket state is [$($webSocket.State.ToString())].`n$socketStatusStr" } try { $this.ReceiveJob = Start-Job -Name ReceiveRtmMessages -ScriptBlock $recv -ArgumentList $this.WebSocketUrl,$this.Config.Credential.GetNetworkCredential().Password -ErrorAction Stop -Verbose $this.Connected = $true $this.Status = [ConnectionStatus]::Connected $this.LogInfo("Started websocket receive job [$($this.ReceiveJob.Id)]") } catch { $this.LogInfo([LogSeverity]::Error, "$($_.Exception.Message)", [ExceptionFormatter]::Summarize($_)) } } # Read all available data from the job [string]ReadReceiveJob() { # Read stream info from the job so we can log them $infoStream = $this.ReceiveJob.ChildJobs[0].Information.ReadAll() $warningStream = $this.ReceiveJob.ChildJobs[0].Warning.ReadAll() $errStream = $this.ReceiveJob.ChildJobs[0].Error.ReadAll() $verboseStream = $this.ReceiveJob.ChildJobs[0].Verbose.ReadAll() $debugStream = $this.ReceiveJob.ChildJobs[0].Debug.ReadAll() foreach($item in $infoStream) { $this.LogInfo($item.ToString()) } foreach($item in $warningStream) { $this.LogInfo([LogSeverity]::Warning, $item.ToString()) } foreach($item in $errStream) { $this.LogInfo([LogSeverity]::Error, $item.ToString()) } foreach($item in $verboseStream) { $this.LogVerbose($item.ToString()) } foreach($item in $debugStream) { $this.LogVerbose($item.ToString()) } # The receive job stopped for some reason. Reestablish the connection if the job isn't running if($this.ReceiveJob.State -ne 'Running') { $this.LogInfo([LogSeverity]::Warning, "Receive job state is [$($this.ReceiveJob.State)]. Attempting to reconnect...") Start-Sleep -Seconds 5 $this.Connect() } if($this.ReceiveJob.HasMoreData) { return $this.ReceiveJob.ChildJobs[0].Output.ReadAll() } else { return $null } } # Stop the receive job [void]Disconnect() { $this.LogInfo('Closing websocket') if($this.ReceiveJob) { $this.LogInfo("Stopping receive job [$($this.ReceiveJob.Id)]") $this.ReceiveJob | Stop-Job -Confirm:$false -PassThru | Remove-Job -Force -ErrorAction SilentlyContinue } $this.Connected = $false $this.Status = [ConnectionStatus]::Disconnected } } enum SparkMessageType { Normal Error Warning } class SparkMessage : Message { [SparkMessageType]$MessageType = [SparkMessageType]::Normal SparkMessage( [string]$To, [string]$From, [string]$Body = [string]::Empty ) { $this.To = $To $this.From = $From $this.Body = $Body } } class SparkPerson : Person { [string]$Email [string]$Type [string]$Status [string]$Avatar [string]$Created [string]$LastActivity } function New-PoshBotSparkBackend { <# .SYNOPSIS Create a new instance of a Spark backend .DESCRIPTION Create a new instance of a Spark backend .PARAMETER Configuration The hashtable containing backend-specific properties on how to create the Spark backend instance. .EXAMPLE PS C:\> $backendConfig = @{Name = 'SparkBackend'; Token = '<SPARK-API-TOKEN>'} PS C:\> $backend = New-PoshBotSparkBackend -Configuration $backendConfig Create a Spark backend using the specified API token .INPUTS Hashtable .OUTPUTS SparkBackend #> [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 Spark backend instance' $backend = [SparkBackend]::new($item.Token) if($item.Name) { $backend.Name = $item.Name } $backend } } } } Export-ModuleMember -Function 'New-PoshBotSparkBackend' |