diff --git a/PoshBot.Gitter.Backend/Classes/GitterBackend.ps1 b/PoshBot.Gitter.Backend/Classes/GitterBackend.ps1 index bc9a8e4..a8713ae 100644 --- a/PoshBot.Gitter.Backend/Classes/GitterBackend.ps1 +++ b/PoshBot.Gitter.Backend/Classes/GitterBackend.ps1 @@ -1,6 +1,4 @@ - class GitterBackend : Backend { - # Constructor GitterBackend ([string]$Token, [string]$RoomId) { $config = [ConnectionConfig]::new() @@ -14,11 +12,10 @@ class GitterBackend : Backend { # Connect to the chat network [void]Connect() { - $this.LogInfo('Connecting to backend') + $this.LogInfo('Connecting to backend...') $this.Connection.Connect() $this.BotId = $this.GetBotIdentity() $this.LoadUsers() - $this.LoadRooms() } # Disconnect from the chat network @@ -39,65 +36,239 @@ class GitterBackend : Backend { } # Receive a message from the chat network - [Message]ReceiveMessage() { - # Implement logic to receive a message from the - # chat network using network-specific APIs. + [Message[]]ReceiveMessage() { + $messages = New-Object -TypeName System.Collections.ArrayList - # This method assumes that a connection to the chat network - # has already been made using $this.Connect() + try { + # Read the output stream from the receive job and get any messages since our last read + [string[]]$jsonResults = $this.Connection.ReadReceiveJob() - # This method should return quickly (no blocking calls) - # so PoshBot can continue in its message processing loop - return $null - } + foreach ($jsonResult in $jsonResults) { + if ($null -ne $jsonResult -and $jsonResult -ne [string]::Empty) { + #Write-Debug -Message "[SlackBackend:ReceiveMessage] Received `n$jsonResult" + $this.LogDebug('Received message', $jsonResult) - # Send a message back to the chat network - [void]SendMessage([Response]$Response) { - # Implement logic to send a message - # back to the chat network + $gitterMessage = @($jsonResult | ConvertFrom-Json) + + $msg = [Message]::new() + $msg.From = $gitterMessage.fromUser.id + $msg.Text = $gitterMessage.text + $msg.Time = $gitterMessage.sent + + # ** 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 } # Return a user object given an Id [Person]GetUser([string]$UserId) { - # Return a [Person] instance (or a class derived from [Person]) - return $null + $user = $this.Users[$UserId] + if (-not $user) { + $this.LogDebug([LogSeverity]::Warning, "User [$UserId] not found. Refreshing users") + $this.LoadUsers() + $user = $this.Users[$UserId] + } + + if ($user) { + $this.LogDebug("Resolved user [$UserId]", $user) + } else { + $this.LogDebug([LogSeverity]::Warning, "Could not resolve user [$UserId]") + } + return $user + } + + # Get all user info by their ID + [hashtable]GetUserInfo([string]$UserId) { + $user = $null + if ($this.Users.ContainsKey($UserId)) { + $user = $this.Users[$UserId] + } else { + $this.LogDebug([LogSeverity]::Warning, "User [$UserId] not found. Refreshing users") + $this.LoadUsers() + $user = $this.Users[$UserId] + } + + if ($user) { + $this.LogDebug("Resolved [$UserId] to [$($user.UserName)]") + return $user.ToHash() + } else { + $this.LogDebug([LogSeverity]::Warning, "Could not resolve channel [$UserId]") + return $null + } + } + + # Add a reaction to an existing chat message + [void]AddReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { + $this.LogDebug("Reactions are not yet supported in Gitter - Ignoring") + # TODO: Must build out once Gitter supports it. + } + + # Remove a reaction from an existing chat message + [void]RemoveReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) { + $this.LogDebug("Reactions are not yet supported in Gitter - Ignoring") + # TODO: Must build out once Gitter supports it. + } + + # Send a message back to Gitter + [void]SendMessage([Response]$Response) { + # Process any custom responses + $this.LogVerbose("[$($Response.Data.Count)] custom responses") + $NL = [System.Environment]::NewLine + 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' { + $this.LogDebug('Custom response is [PoshBot.Card.Response]') + $t = '```' + $NL + $customResponse.Text + '```' + $this.SendGitterMessage($t) + break + } + '(.*?)PoshBot\.Text\.Response' { + $this.LogDebug('Custom response is [PoshBot.Text.Response]') + $t = '```' + $NL + $customResponse.Text + '```' + $this.SendGitterMessage($t) + break + } + '(.*?)PoshBot\.File\.Upload' { + $this.LogDebug('Custom response is [PoshBot.File.Upload]') + $this.LogVerbose('Not currently implemented') + break + } + } + } + + if ($Response.Text.Count -gt 0) { + foreach ($t in $Response.Text) { + $this.LogDebug("Sending response back to Gitter channel [$($Response.To)]", $t) + $t = '```' + $NL + $t + '```' + $this.SendGitterMessage($t) + } + } + } + + [void]SendGitterMessage([string]$message) { + $token = $this.Connection.Config.Credential.GetNetworkCredential().Password + $roomId = $this.Connection.Config.Endpoint + $restParams = @{ + Method = 'Post' + ContentType = 'application/json' + Verbose = $false + Headers = @{ + Authorization = "Bearer $($token)" + } + Uri = "https://api.gitter.im/v1/rooms/$roomId/chatMessages" + Body = @{ + text = "$message" + } | ConvertTo-Json + } + + $gitterResponse = Invoke-RestMethod @restParams } # Resolve a user name to user id [string]UsernameToUserId([string]$Username) { - # Do something using the chat network APIs to - # resolve a username to an Id and return it - return '12345' + $Username = $Username.TrimStart('@') + $user = $this.Users.Values | Where-Object {$_.UserName -eq $Username} + $id = $null + + if ($user) { + $id = $user.Id + } else { + # User each doesn't exist or is not in the local cache + # Refresh it and try again + $this.LogDebug([LogSeverity]::Warning, "User [$Username] not found. Refreshing users") + $this.LoadUsers() + $user = $this.Users.Values | Where-Object {$_.Nickname -eq $Username} + + if (-not $user) { + $id = $null + } else { + $id = $user.Id + } + } + if ($id) { + $this.LogDebug("Resolved [$Username] to [$id]") + } else { + $this.LogDebug([LogSeverity]::Warning, "Could not resolve user [$Username]") + } + + return $id } # Resolve a user ID to a username/nickname [string]UserIdToUsername([string]$UserId) { - # Do something using the network APIs to - # resolve a username from an Id and return it - return 'JoeUser' + $name = $null + if ($this.Users.ContainsKey($UserId)) { + $name = $this.Users[$UserId].UserName + } else { + $this.LogDebug([LogSeverity]::Warning, "User [$UserId] not found. Refreshing users") + $this.LoadUsers() + $name = $this.Users[$UserId].UserName + } + + if ($name) { + $this.LogDebug("Resolved [$UserId] to [$name]") + } else { + $this.LogDebug([LogSeverity]::Warning, "Could not resolve user [$UserId]") + } + + return $name } - [void]LoadUsers() { - $this.LogDebug('Getting Gitter Room Users') + # Get the bot identity Id + [string]GetBotIdentity() { + $id = $this.Connection.LoginData.id + $this.LogVerbose("Bot identity is [$id]") + return $id + } - #$allUsers = Get-Slackuser -Token $this.Connection.Config.Credential.GetNetworkCredential().Password -Verbose:$false + # 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 + } - $token = $this.Config.Credential.GetNetworkCredential().Password - $roomId = $this.Config.Endpoint + [void]LoadUsers() { + $this.LogVerbose('Getting Gitter Room Users...') + + $token = $this.Connection.Config.Credential.GetNetworkCredential().Password + $roomId = $this.Connection.Config.Endpoint $restParams = @{ ContentType = 'application/json' - Verbose = $false - Headers = @{ + Verbose = $false + Headers = @{ Authorization = "Bearer $($token)" } - Uri = "https://api.gitter.im/v1/rooms/$roomId/users" + Uri = "https://api.gitter.im/v1/rooms/$roomId/users" } + $allUsers = Invoke-RestMethod @restParams - $this.LogDebug("[$($allUsers.Count)] users returned") + $this.LogVerbose("[$($allUsers.Count)] users returned") $allUsers | ForEach-Object { $user = [GitterPerson]::new() $user.Id = $_.id + $user.UserName = $_.username $user.DisplayName = $_.displayname $user.Url = $_.url $user.AvatarUrl = $_.avatarUrl @@ -108,7 +279,7 @@ class GitterBackend : Backend { $user.GV = $_.gv if (-not $this.Users.ContainsKey($_.ID)) { $this.LogDebug("Adding user [$($_.ID):$($_.Name)]") - $this.Users[$_.ID] = $user + $this.Users[$_.ID] = $user } } diff --git a/PoshBot.Gitter.Backend/Classes/GitterConnection.ps1 b/PoshBot.Gitter.Backend/Classes/GitterConnection.ps1 index 7bf4672..182efa6 100644 --- a/PoshBot.Gitter.Backend/Classes/GitterConnection.ps1 +++ b/PoshBot.Gitter.Backend/Classes/GitterConnection.ps1 @@ -1,5 +1,7 @@ - class GitterConnection : Connection { + [pscustomobject]$LoginData + [bool]$Connected + [object]$ReceiveJob = $null GitterConnection() { # Implement any needed initialization steps @@ -7,13 +9,114 @@ class GitterConnection : Connection { # Connect to the chat network [void]Connect() { - # Use the configuration stored in $this.Config (inherited from base class) - # to connect to the chat network + $token = $this.Config.Credential.GetNetworkCredential().Password + $restParams = @{ + ContentType = 'application/json' + Verbose = $false + Headers = @{ + Authorization = "Bearer $($token)" + } + Uri = "https://api.gitter.im/v1/user" + } + + $currentUser = Invoke-RestMethod @restParams + $this.LoginData = $currentUser[0] + + if ($null -eq $this.ReceiveJob -or $this.ReceiveJob.State -ne 'Running') { + $this.LogDebug('Connecting to Gitter Streaming API') + $this.StartReceiveJob() + } else { + $this.LogDebug([LogSeverity]::Warning, 'Receive job is already running') + } } # Disconnect from the chat network [void]Disconnect() { - # Use the configuration stored in $this.Config (inherited from base class) - # to disconnect to the chat network + $this.LogInfo('Closing connection...') + 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 + } + + # 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 + } + } + + [void]StartReceiveJob() { + $recv = { + [cmdletbinding()] + param( + [parameter(mandatory)] + $token, + [parameter(mandatory)] + $roomId + ) + + # Connect to Gitter + Write-Verbose "[GitterBackend:ReceiveJob] Connecting to RoomId [$($roomId)]" + + Add-Type -AssemblyName System.Net.Http + $httpClient = New-Object System.Net.Http.Httpclient + $httpClient.DefaultRequestHeaders.Add("Authorization", "Bearer $token"); + + $stream = $httpClient.GetStreamAsync("https://stream.gitter.im/v1/rooms/$roomId/chatMessages").Result + + $streamReader = New-Object System.IO.StreamReader $stream + + $line = $null; + while ($null -ne ($line = $streamReader.ReadLine())) + { + # Ignore heartbeat message + if($line -ne " ") { + $line + } + } + } + + try { + $this.ReceiveJob = Start-Job -Name ReceiveGitterMessages -ScriptBlock $recv -ArgumentList $this.Config.Credential.GetNetworkCredential().Password, $this.Config.Endpoint -ErrorAction Stop -Verbose + $this.Connected = $true + $this.Status = [ConnectionStatus]::Connected + $this.LogInfo("Started streaming API receive job [$($this.ReceiveJob.Id)]") + } catch { + $this.LogInfo([LogSeverity]::Error, "$($_.Exception.Message)", [ExceptionFormatter]::Summarize($_)) + } } } diff --git a/PoshBot.Gitter.Backend/Public/New-PoshBotGitterBackend.ps1 b/PoshBot.Gitter.Backend/Public/New-PoshBotGitterBackend.ps1 index 2e1b296..e02c7f7 100644 --- a/PoshBot.Gitter.Backend/Public/New-PoshBotGitterBackend.ps1 +++ b/PoshBot.Gitter.Backend/Public/New-PoshBotGitterBackend.ps1 @@ -9,7 +9,7 @@ function New-PoshBotGitterBackend { Hashtable of required properties needed by the backend to initialize and connect to the backend chat network. .EXAMPLE - PS C:\> $config = @{Name = 'GitterBackend'; Token = ''} + PS C:\> $config = @{Name = 'GitterBackend'; Token = ''; RoomId = ''} PS C:\> $backend = New-PoshBotGitterBackend -Configuration $config Create a hashtable containing required properties for the backend @@ -34,11 +34,7 @@ function New-PoshBotGitterBackend { } else { Write-Verbose 'Creating new Gitter backend instance' - # Note that [token] is just an example - # In a real backend plugin, you would pass any - # needed information from $Configuration to - # the constructor - $backend = [GitterBackend]::new($item.Token) + $backend = [GitterBackend]::new($item.Token, $item.RoomId) if ($item.Name) { $backend.Name = $item.Name } diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..810bddb --- /dev/null +++ b/start.ps1 @@ -0,0 +1,49 @@ +# Import necessary modules +Import-Module PoshBot +Import-Module C:\github_local\gep13\PoshBot.Gitter.Backend\out\PoshBot.Gitter.Backend\0.1.0\PoshBot.Gitter.Backend.psm1 + +# Store config path in variable +$configPath = 'C:\temp\poshbot\Gitter\GitterConfig.psd1' + +# Create hashtable of parameters for New-PoshBotConfiguration +$botParams = @{ + # The friendly name of the bot instance + Name = 'GitterBot' + # The primary email address(es) of the admin(s) that can manage the bot + BotAdmins = @('gep13') + # Universal command prefix for PoshBot. + # If the message includes this at the start, PoshBot will try to parse the command and + # return an error if no matching command is found + CommandPrefix = '!' + # PoshBot log level. + LogLevel = 'Verbose' + # The path containing the configuration files for PoshBot + ConfigurationDirectory = 'C:\temp\poshbot\Gitter' + # The path where you would like the PoshBot logs to be created + LogDirectory = 'C:\temp\poshbot\Gitter' + # The path containing your PoshBot plugins + PluginDirectory = 'c:\temp\poshbot\Plugins' + + # You will need to populate this with a Token and RoomId + # that you would like this Backend to work with + BackendConfiguration = @{ + Token = "" + RoomId = "" + Name = 'GitterBackend' + } +} + +# Create the bot backend +$backend = New-PoshBotGitterBackend -Configuration $botParams.BackendConfiguration + +# Create the bot configuration +$myBotConfig = New-PoshBotConfiguration @botParams + +# Save bot configuration +Save-PoshBotConfiguration -InputObject $myBotConfig -Path $configPath -Force + +# Create the bot instance from the backend and configuration path +$bot = New-PoshBotInstance -Backend $backend -Path $configPath + +# Start the bot +$bot | Start-PoshBot \ No newline at end of file