Classes/GChatBackend.ps1

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

    # The types of message that we care about from GChat
    # All othere will be ignored
    [string[]]$MessageTypes = @(
        'MESSAGE'
        'REMOVED_FROM_SPACE'
        'ADDED_TO_SPACE'
        'CARD_CLICKED'
    )

    [int]$MaxMessageLength = 4000

    GChatBackend ([string]$ConfigName,[string]$SheetId,[string]$SheetName,[int]$PollingFrequency) {
        if (!(Get-Module PSGSuite)) {
            Import-Module PSGSuite -MinimumVersion "2.13.0" -Verbose:$false -Force
        }
        if ((Show-PSGSuiteConfig).ConfigName -ne $ConfigName) {
            Switch-PSGSuiteConfig -ConfigName $ConfigName
        }
        $config = [ConnectionConfig]::new()
        $config.Credential = New-Object System.Management.Automation.PSCredential($ConfigName,(ConvertTo-SecureString -String $SheetName -AsPlainText -Force))
        $config.Endpoint = $SheetId
        $conn = [GChatConnection]::New($ConfigName,$SheetId,$SheetName,$PollingFrequency)
        $conn.Config = $config
        $this.Connection = $conn
    }

    # Connect to GChat
    [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()
        $this.LoadRooms()
        $this.LoadUsers()
    }

    # 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 "[GChatBackend:ReceiveMessage] Received `n$jsonResult"
                $this.LogDebug('Received message', $jsonResult)

                $gChatMessages = @($jsonResult | ConvertFrom-Json)
                foreach ($gChatMessage in $gChatMessages) {
                    $gChatEvent = ConvertFrom-Json $gChatMessage.Event

                    # We only care about certain message types from GChat
                    if ($gChatEvent.type -in $this.MessageTypes) {
                        $msg = [Message]::new()

                        # Set the message type and optionally the subtype
                        #$msg.Type = $gChatEvent.type
                        $this.LogVerbose("New [$($gChatEvent.type)] Chat event received")
                        switch ($gChatEvent.type) {
                            'ADDED_TO_SPACE' {
                                $msg.Type = [MessageType]::Message
                                $msg.SubType = [MessageSubType]::ChannelJoined
                                $msg.To = $gChatEvent.space.name
                                $msg.ToName = $gChatEvent.space.displayName
                                $msg.Text = ($gChatMessage.Event -join '')
                            }
                            'REMOVED_FROM_SPACE' {
                                $msg.Type = [MessageType]::Message
                                $msg.SubType = [MessageSubType]::ChannelLeft
                                $msg.Text = ($gChatMessage.Event -join '')
                            }
                            'MESSAGE' {
                                $msg.Type = [MessageType]::Message
                                $msg.From = $gChatEvent.message.sender.name -replace "users\/",""
                                $msg.FromName = $gChatEvent.message.sender.displayName
                                $msg.To = $gChatEvent.message.thread.name
                                $msg.ToName = $gChatEvent.message.space.displayName
                                $msg.Text = $gChatEvent.message.argumentText.Trim().Replace(' ',' ').Replace(' ',' ')
                                $msg.Id = $gChatEvent.message.name
                                if ($gChatEvent.space.type -eq 'DM') {
                                    $this.LogDebug("MESSAGE is a DM!")
                                    $msg.IsDM = $true
                                    $msg.ToName = "@$($gChatEvent.user.displayName)"
                                }
                            }
                            'CARD_CLICKED' {
                                $msg.Type = [MessageType]::PresenceChange # Hack for now since Google Chat doesn't support presence change and CardClicked is not currently available as a message type in PoshBot. This maps to CARD_CLICKED events sent from Google Chat only!
                                $msg.From = $gChatEvent.user.name
                                $msg.FromName = $gChatEvent.user.displayName
                                $msg.To = $gChatEvent.message.name
                                $msg.ToName = $gChatEvent.message.space.displayName
                                $msg.Text = ($gChatMessage.Event -join '')
                                $msg.Id = $gChatEvent.message.name
                                if ($gChatEvent.space.type -eq 'DM') {
                                    $this.LogDebug("CARD_CLICKED event is a DM!")
                                    $msg.IsDM = $true
                                    $msg.ToName = "@$($gChatEvent.user.displayName)"
                                }
                            }
                        }

                        $this.LogDebug("Message type is [$($msg.Type)`:$($msg.Subtype)] :: From [$($msg.FromName)`:$($msg.From)] :: To [$($msg.ToName)`:$($msg.To)]")

                        $msg.RawMessage = $gChatMessage
                        $this.LogDebug('Raw message', $gChatMessage)
                        # Get time of message
                        $unixEpoch = [datetime]'1970-01-01T00:00:00.0000Z'
                        $msg.Time = if ($gChatEvent.eventTime.seconds) {
                            $unixEpoch.AddSeconds($gChatEvent.eventTime.seconds)
                        }
                        else {
                            (Get-Date).ToUniversalTime()
                        }
                        if ($gChatEvent.type -eq 'REMOVED_FROM_SPACE') {
                            $messages.Add($msg) | Out-Null
                            $this.LoadRooms()
                        }
                        elseif ($gChatEvent.type -eq 'ADDED_TO_SPACE') {
                            $messages.Add($msg) | Out-Null
                            $this.LoadRooms()
                        }
                        else {
                            $messages.Add($msg) | Out-Null
                        }
                    } 
                    else {
                        $this.LogDebug("Message type is [$($gChatEvent.type)]. Ignoring and marking as complete")
                        $fullSheet = Import-GSSheet -SpreadsheetId $this.SheetId -SheetName $this.SheetName -Range "A1:D" -ErrorAction Stop
                        $fullSheetCount = if (!$fullSheet.Count) {
                            1
                        }
                        else {
                            $fullSheet.Count
                        }
                        for ($i = 0; $i -lt $fullSheetCount; $i++) {
                            if ($fullSheet[$i].Id -eq $gChatMessage.Id) {
                                break
                            }
                        }
                        $rowId = $i + 2
                        Export-GSSheet -SpreadsheetId $this.SheetId -Value "Yes" -SheetName $this.SheetName -Range "C$($rowId)" -ErrorAction Stop | Out-Null
                    }
                }
            }
        }
        catch {
            Write-Error $_
        }
        return $messages
    }

    # Send a GChat ping - (not really needed for this implementation)
    [void]Ping() { }

    # Send a message back to GChat
    [void]SendMessage([Response]$Response) {
            if ((Show-PSGSuiteConfig).ConfigName -ne $this.Connection.ConfigName) {
                Switch-PSGSuiteConfig $this.Connection.ConfigName -Verbose
            }
            # Process any custom responses
            $this.LogVerbose("[$($Response.Data.Count)] custom responses and [$($Response.Text.Count)] text responses")
            $this.LogVerbose("Message Details :: [ConfigName:$($this.Connection.ConfigName) | SheetId:$($this.Connection.SheetId) | SheetName:$($this.Connection.SheetName) | PollingFrequency:$($this.Connection.PollingFrequency)]")
            foreach ($customResponse in $Response.Data) {

                [string]$sendTo = $Response.To
                if ($customResponse.DM) {
                    $rawMessageType = (ConvertFrom-Json $Response.OriginalMessage.RawMessage.Event).space.type
                    if ($rawMessageType -ne 'DM') {
                        $this.LogVerbose("Response is [DM] and original message space type is [$($rawMessageType)] - parsing UserID to DM Name")
                        $sendToHash = $this.UserIdToDMName("users/$($Response.MessageFrom)")
                        if ($sendToHash.ContainsKey('name')) {
                            $sendTo = $sendToHash['name']
                            $this.LogVerbose("UserID [$($Response.MessageFrom)] successfully parsed to DM Name [$sendTo]")
                            $respText = "<users/$($Response.MessageFrom)> The information you requested has been sent to you via Direct Message. Thank you!"
                            Send-GSChatMessage -Text $respText -Thread $Response.To -Parent "$($Response.To.Split("/")[0..1] -join "/")"
                        }
                        else {
                            $respText = "<users/$($Response.MessageFrom)> Your request was received, but the information requested is only available to be sent via Direct Message. Please open a Direct Message with me first then submit your command again. Thank you!"
                            Send-GSChatMessage -Text $respText -Thread $Response.To -Parent "$($Response.To.Split("/")[0..1] -join "/")"
                            break
                        }
                    }
                    else {
                        $this.LogVerbose("Response is [DM] and original message space type is [$($rawMessageType)] - no need to parse DM Name")
                    }
                }
                
                switch -Regex ($customResponse.PSObject.TypeNames[0]) {
                    '(.*?)PoshBot\.Card\.Response' {
                        $this.LogVerbose("Custom response is [$($customResponse.PSObject.TypeNames[0])]")
                        $sendParams = @{}
                        $fbText = ''
                        if ($customResponse.CustomData) {
                            $this.LogVerbose("The response includes CustomData! Parsing...")
                            $deserializedItem = try {
                                [System.Management.Automation.PSSerializer]::Deserialize($customResponse.CustomData)
                                $this.LogVerbose("CardResponse::CustomData :: Type [$($customResponse.CustomData.PSObject.TypeNames[0])] :: Succesfully deserialized")
                            }
                            catch {
                                try {
                                    if ($customResponse.CustomData -is [System.Collections.Hashtable] -or $customResponse.CustomData -is [System.Management.Automation.PSCustomObject]) {
                                        $this.LogVerbose("CardResponse::CustomData :: Type [$($customResponse.CustomData.PSObject.TypeNames[0])] :: Item is already correct type")
                                        $customResponse.CustomData
                                    }
                                    elseif ($jsonConvert = ConvertFrom-Json $customResponse.CustomData) {
                                        $this.LogVerbose("CardResponse::CustomData :: Type [$($customResponse.CustomData.PSObject.TypeNames[0])] :: Item is a JSON string, returning converted object")
                                        $jsonConvert
                                    }
                                    else {
                                        $null
                                    }
                                }
                                catch {
                                    $null
                                }
                            }
                            if ($deserializedItem.token -and $deserializedItem.body) {
                                $this.LogVerbose("Deserialized Body", $deserializedItem.body)
                                $this.LogVerbose("Deserialized Token Present", $($null -ne $deserializedItem.token))
                                $deserBody = ConvertTo-Json -InputObject $deserializedItem.body -Depth 20
                                $restParams = @{
                                    ContentType = 'application/json'
                                    Verbose = $false
                                    Headers = @{
                                        Authorization = "Bearer $($deserializedItem.token)"
                                    }
                                    Body = $deserBody
                                }
                                if ($sendTo -like "spaces/*/messages/*") {
                                    $this.LogVerbose("Updating parsed message [$sendTo]")
                                    $updateMask = @()
                                    if ($deserializedItem.body.text) {
                                        $updateMask += 'text'
                                    }
                                    if ($deserializedItem.body.cards) {
                                        $updateMask += 'cards'
                                    }
                                    $restParams['Uri'] = ([Uri]"https://chat.googleapis.com/v1/$($sendTo)?updateMask=$($updateMask -join ',')")
                                    $restParams['Method'] = 'Put'
                                }
                                elseif ($sendTo -like "spaces/*/threads/*") {
                                    $this.LogVerbose("Sending parsed response to thread [$sendTo]")
                                    $deserializedItem.body | Add-Member -MemberType NoteProperty -Name thread -Value $(@{
                                        name = $sendTo
                                    }) -Force
                                    $newDeserBody = ConvertTo-Json -InputObject $deserializedItem.body -Depth 20
                                    $restParams['Body'] = $newDeserBody
                                    $updatedUri = "https://chat.googleapis.com/v1/$($sendTo.Split("/")[0..1] -join "/")/messages"
                                    $restParams['Uri'] = ([Uri]$updatedUri)
                                    $restParams['Method'] = 'Post'
                                }
                                else {
                                    $this.LogVerbose("Sending parsed message to space [$sendTo]")
                                    $restParams['Uri'] = ([Uri]"https://chat.googleapis.com/v1/$($sendTo)/messages")
                                    $restParams['Method'] = 'Post'
                                }
                                Invoke-RestMethod @restParams
                            }
                            else {
                                $this.LogInfo([LogSeverity]::Warning, "Unable to parse Card's CustomData as a GChat response and token! SKIPPING", $customResponse.CustomData)
                            }
                        }
                        else {
                            $this.LogVerbose("The response DOES NOT include CustomData! Parsing PoshBot CardResponse to Google Chat Card object...")
                            $widgets = @()
                            $cardParams = @{}
                            if (-not [string]::IsNullOrEmpty($customResponse.Text)) {
                                $this.LogDebug("Response size [$($customResponse.Text.Length)]")
                                $formattedText = if ($customResponse.LinkUrl) {
                                    "<$($customResponse.LinkUrl)|$($customResponse.Text)>"
                                }
                                else {
                                    $customResponse.Text
                                }
                                $sendParams.Text = $formattedText
                                $fbText = $customResponse.Text
                            }
                            elseif ($customResponse.LinkUrl) {
                                $sendParams.Text = "<$($customResponse.LinkUrl)|View Details>"
                                $fbText = $customResponse.LinkUrl
                            }
                            $sendParams.FallbackText = $fbText
                            if ($customResponse.Title) {
                                $cardParams.HeaderTitle = $customResponse.Title
                            }
                            if ($customResponse.ThumbnailUrl) {
                                $cardParams.HeaderImageUrl = $customResponse.ThumbnailUrl
                                $cardParams.HeaderImageStyle = 'AVATAR'
                            }
                            if ($customResponse.Fields) {
                                $widgets += foreach ($key in $customResponse.Fields.Keys) {
                                    Add-GSChatKeyValue -TopLabel $key -Content $customResponse.Fields[$key]
                                }
                            }
                            if ($customResponse.ImageUrl) {
                                $widgets += Add-GSChatImage -ImageUrl $customResponse.ImageUrl -LinkImage
                            }
                            if ($widgets) {
                                $cardParams.MessageSegment = $widgets
                            }
                            if ($cardParams.Keys.Count) {
                                $card = Add-GSChatCard @cardParams
                                $sendParams.MessageSegment = $card
                            }
                            if ($sendTo -like "spaces/*/messages/*") {
                                $this.LogVerbose("Updating message [$sendTo]", $sendParams)
                                try {
                                    Update-GSChatMessage @sendParams -MessageId $sendTo -Verbose:$false -ErrorAction Stop
                                }
                                catch {
                                    $this.LogInfo([LogSeverity]::Error, $_.Exception.Message, $_)
                                }
                            }
                            elseif ($sendTo -like "spaces/*/threads/*") {
                                $this.LogVerbose("Sending response to thread [$sendTo]", $sendParams)
                                try {
                                    Send-GSChatMessage @sendParams -Thread $sendTo -Parent $($sendTo.Split("/")[0..1] -join "/") -Verbose:$false -ErrorAction Stop
                                }
                                catch {
                                    $this.LogInfo([LogSeverity]::Error, $_.Exception.Message, $_)
                                }
                            }
                            else {
                                $this.LogVerbose("Sending message to space [$sendTo]", $sendParams)
                                try {
                                    Send-GSChatMessage @sendParams -Parent $sendTo -Verbose:$false -ErrorAction Stop
                                }
                                catch {
                                    $this.LogInfo([LogSeverity]::Error, $_.Exception.Message, $_)
                                }
                            }
                        }
                        break
                    }
                    '(.*?)PoshBot\.Text\.Response' {
                        $this.LogVerbose("Custom response is [$($customResponse.PSObject.TypeNames[0])]")
                        $chunks = $this._ChunkString($customResponse.Text)
                        $i = 0
                        foreach ($chunk in $chunks) {
                            $t = if ($customResponse.AsCode) {
                                '```' + $chunk + '```'
                            } else {
                                $chunk
                            }
                            if ($sendTo -like "spaces/*/messages/*") {
                                $this.LogDebug("Updating message [$sendTo]", $t)
                                Update-GSChatMessage -MessageId $sendTo -Text $t -UpdateMask text -Verbose:$false
                            }
                            elseif ($sendTo -like "spaces/*/threads/*") {
                                $this.LogDebug("Sending response to thread [$sendTo]", $t)
                                Send-GSChatMessage -Text $t -Thread $sendTo -Parent $($sendTo.Split("/")[0..1] -join "/") -Verbose:$false
                            }
                            else {
                                $this.LogDebug("Sending message to space [$sendTo]", $t)
                                Send-GSChatMessage -Text $t -Parent $sendTo -Verbose:$false
                            }
                            $i++
                        }
                        break
                    }
                    '(.*?)PoshBot\.File\.Upload' {
                        $this.LogInfo([LogSeverity]::Error, "Custom response is [$($customResponse.PSObject.TypeNames[0])]. Google Chat does not currently support File Upload via API/SDK call.")
                        # TODO: Must build out once Google Chat supports it.
                        break
                    }
                    default {
                        $this.LogVerbose("Custom response is [$($customResponse.PSObject.TypeNames[0])]")
                    }
                }
            }
            if ($Response.Text.Count -gt 0) {
                [string]$sendTo = $Response.To
                if ($customResponse.DM) {
                    $sendToHash = "$($this.UserIdToDMName($Response.MessageFrom))"
                    if ($sendToHash.ContainsKey('name')) {
                        $sendTo = $sendToHash['name']
                    }
                }
                $i = 0
                $total = $Response.Text.Count
                foreach ($item in $Response.Text) {
                    $i++
                    $deserializedItem = try {
                        [System.Management.Automation.PSSerializer]::Deserialize($item)
                        $this.LogVerbose("Text Item [$i/$total] :: Type [$($item.PSObject.TypeNames[0])] :: Succesfully deserialized")
                    }
                    catch {
                        try {
                            if ($item -is [System.Collections.Hashtable] -or $item -is [System.Management.Automation.PSCustomObject]) {
                                $this.LogVerbose("Text Item [$i/$total] :: Type [$($item.PSObject.TypeNames[0])] :: Item is already correct type")
                                $item
                            }
                            elseif ($jsonConvert = ConvertFrom-Json $item) {
                                $this.LogVerbose("Text Item [$i/$total] :: Type [$($item.PSObject.TypeNames[0])] :: Item is a JSON string, returning converted object")
                                $jsonConvert
                            }
                            else {
                                $null
                            }
                        }
                        catch {
                            $null
                        }
                    }
                    if ($deserializedItem.token -and $deserializedItem.body) {
                        $this.LogVerbose("Deserialized Body", $deserializedItem.body)
                        $this.LogVerbose("Deserialized Token Present", $($null -ne $deserializedItem.token))
                        $deserBody = ConvertTo-Json -InputObject $deserializedItem.body -Depth 20
                        $restParams = @{
                            ContentType = 'application/json'
                            Verbose = $false
                            Headers = @{
                                Authorization = "Bearer $($deserializedItem.token)"
                            }
                            Body = $deserBody
                        }
                        if ($sendTo -like "spaces/*/messages/*") {
                            $this.LogVerbose("Updating parsed message [$sendTo]")
                            $updateMask = @()
                            if ($deserializedItem.body.text) {
                                $updateMask += 'text'
                            }
                            if ($deserializedItem.body.cards) {
                                $updateMask += 'cards'
                            }
                            $restParams['Uri'] = ([Uri]"https://chat.googleapis.com/v1/$($sendTo)?updateMask=$($updateMask -join ',')")
                            $restParams['Method'] = 'Put'
                            Invoke-RestMethod @restParams
                        }
                        elseif ($sendTo -like "spaces/*/threads/*") {
                            $this.LogVerbose("Sending parsed response to thread [$sendTo]")
                            $deserializedItem.body | Add-Member -MemberType NoteProperty -Name thread -Value $(@{
                                name = $sendTo
                            }) -Force
                            $newDeserBody = ConvertTo-Json -InputObject $deserializedItem.body -Depth 20
                            $restParams['Body'] = $newDeserBody
                            $updatedUri = "https://chat.googleapis.com/v1/$($sendTo.Split("/")[0..1] -join "/")/messages"
                            $restParams['Uri'] = ([Uri]$updatedUri)
                            $restParams['Method'] = 'Post'
                            Invoke-RestMethod @restParams
                        }
                        else {
                            $this.LogVerbose("Sending parsed message to space [$sendTo]")
                            $restParams['Uri'] = ([Uri]"https://chat.googleapis.com/v1/$($sendTo)/messages")
                            $restParams['Method'] = 'Post'
                            Invoke-RestMethod @restParams
                        }
                    }
                    else {
                        $chunks = $this._ChunkString($item)
                        foreach ($t in $chunks) {
                            $this.LogDebug("Sending response back to GChat channel [$($Response.To)]", $t)
                            if ($Response.To -like "spaces/*/messages/*") {
                                $this.LogDebug("Updating message [$($Response.To)]", $t)
                                Update-GSChatMessage -MessageId $Response.To -Text $t -UpdateMask text -Verbose:$false
                            }
                            elseif ($Response.To -like "spaces/*/threads/*") {
                                $this.LogDebug("Sending response to thread [$($Response.To)]", $t)
                                Send-GSChatMessage -Text $t -Thread $Response.To -Parent $($Response.To.Split("/")[0..1] -join "/") -Verbose:$false
                            }
                            else {
                                $this.LogDebug("Sending message to space [$($Response.To)]", $t)
                                Send-GSChatMessage -Text $t -Parent $Response.To -Verbose:$false
                            }
                        }
                    }
                }
            }
    }

    # Add a reaction to an existing chat message
    [void]AddReaction([Message]$Message, [ReactionType]$Type, [string]$Reaction) {
        $this.LogDebug("Reactions are not yet supported in Google Chat - Ignoring")
        # TODO: Must build out once Google Chat 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 Google Chat - Ignoring")
        # TODO: Must build out once Google Chat supports it.
    }

    # 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
        }
        $this.LogDebug("Resolved channel [$ChannelName] to [$channelId]")
        return $channelId
    }

    # Populate the list of users the GChat team
    [void]LoadUsers() {
        $this.LogVerbose('Getting Google Chat users')
        $allUsers = Get-GSUser -Filter "isSuspended -eq '$false' changePasswordAtNextLogin -eq '$false'" -Verbose:$false
        $this.LogVerbose("[$($allUsers.Count)] users returned")
        $allUsers | ForEach-Object {
            $user = [GChatPerson]::new()
            $user.Id = "users/$($_.Id)"
            $user.NickName = $_.Name.FullName
            $user.FullName = $_.Name.FullName
            $user.FirstName = $_.Name.GivenName
            $user.LastName = $_.Name.FamilyName
            $user.Email = $_.PrimaryEmail
            $user.Phones = $_.Phones
            $user.IsAdmin = $_.IsAdmin
            $user.IsDelegatedAdmin = $_.IsDelegatedAdmin
            $user.IsEnforcedIn2Sv = $_.IsEnforcedIn2Sv
            $user.IsEnrolledIn2Sv = $_.IsEnrolledIn2Sv
            $user.OrgUnitPath = $_.OrgUnitPath
            $user.CreationTimeRaw = $_.CreationTimeRaw
            $user.CreationTime = $_.CreationTime
            $user.LastLoginTimeRaw = $_.LastLoginTimeRaw
            $user.LastLoginTime = $_.LastLoginTime
            $user.ThumbnailPhotoUrl = $_.ThumbnailPhotoUrl
            if (-not $this.Users.ContainsKey("users/$($_.Id)")) {
                $this.LogDebug("Adding user [users/$($_.Id):$($_.Name.FullName)]")
                $this.Users["users/$($_.Id)"] =  $user
            }
        }

        foreach ($key in $this.Users.Keys | Where-Object {($_ -replace 'users\/','') -notin $allUsers.Id}) {
            $this.LogDebug("Removing outdated user [$key]")
            $this.Users.Remove($key)
        }
    }

    # Populate the list of channels in the GChat team
    [void]LoadRooms() {
        $this.LogVerbose('Getting Google Chat spaces')
        $allChannels = Get-GSChatSpace -Verbose:$false
        $this.LogVerbose("[$($allChannels.Count)] spaces returned")

        $allChannels | ForEach-Object {
            $channel = [GChatChannel]::new()
            $channel.Id = $_.Name
            if ($_.DisplayName) {
                $channel.Name = $_.DisplayName
            }
            else {
                $channel.Name = "DM"
            }
            $channel.Type = $_.Type
            $channelMembers = Get-GSChatMember -Space $_.Name -Verbose:$false
            $channel.MemberCount = $channelMembers.Count
            foreach ($member in $channelMembers) {
                $channel.Members.Add($member, $null)
            }
            $this.LogDebug("Adding space: $($_.DisplayName):$($_.Name)")
            $this.Rooms[$_.Name] = $channel
        }

        foreach ($key in $this.Rooms.Keys | Where-Object {$_ -notin $allChannels.Name}) {
            $this.LogDebug("Removing outdated channel [$key]")
            $this.Rooms.Remove($key)
        }
    }

    # Get the bot identity Id
    [string]GetBotIdentity() {
        $id = $this.Connection.LoginData.self.id
        $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
    [GChatPerson]GetUser([string]$UserId) {
        $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
    }

    [hashtable]GetUserInfo([string]$UserId) {
        if ($UserId -notlike "users/*") {
            $UserId = "users/$UserId"
        }
        $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)
            return $user.ToHash()
        } else {
            $this.LogDebug([LogSeverity]::Warning, "Could not resolve user [$UserId]")
            return $null
        }
    }

    # Get a user Id by their name
    [string]UsernameToUserId([string]$Username) {
        $Username = $Username.TrimStart('@')
        $user = $this.Users.Values | Where-Object {$_.Nickname -eq $Username -or $_.Email -eq $Username -or $_.FullName -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 -or $_.Email -eq $Username -or $_.FullName -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
    }

    # Get a user name by their Id
    [string]UserIdToUsername([string]$UserId) {
        $name = $null
        if ((Get-GSChatConfig).Spaces.ContainsKey("$UserId")) {
            $name = $this.Users[$UserId].Nickname
        } else {
            $this.LogDebug([LogSeverity]::Warning, "User [$UserId] not found. Refreshing users")
            $this.LoadUsers()
            $name = $this.Users[$UserId].Nickname
        }
        if ($name) {
            $this.LogDebug("Resolved [$UserId] to [$name]")
        } else {
            $this.LogDebug([LogSeverity]::Warning, "Could not resolve user [$UserId]")
        }
        return $name
    }

    # Get a user name by their Id
    [hashtable]UserIdToDMName([string]$UserId) {
        $hash = @{}
        if ((Get-GSChatConfig).Spaces.ContainsKey($UserId)) {
            $hash['name'] = (Get-GSChatConfig).Spaces[$UserId]
        }
        if ($hash.ContainsKey('name')) {
            $this.LogDebug("Resolved [$UserId] to DM name [$($hash['name'])]")
        } 
        else {
            $this.LogDebug([LogSeverity]::Warning, "Could not resolve user [$UserId] to a DM. Advising user to DM the bot to initialize the space first.")
        }
        return $hash
    }

    # Get the channel name by Id
    [string]ChannelIdToName([string]$ChannelId) {
        $name = $null
        if ($this.Rooms.ContainsKey($ChannelId)) {
            $name = $this.Rooms[$ChannelId].Name
        } else {
            $this.LogDebug([LogSeverity]::Warning, "Channel [$ChannelId] not found. Refreshing channels")
            $this.LoadRooms()
            $name = $this.Rooms[$ChannelId].Name
        }
        if ($name) {
            $this.LogDebug("Resolved [$ChannelId] to [$name]")
        } else {
            $this.LogDebug([LogSeverity]::Warning, "Could not resolve channel [$ChannelId]")
        }
        return $name
    }

    # 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 'white_check_mark' }
            'Failure'        { return 'exclamation' }
            'Processing'     { return 'gear' }
            'Warning'        { return 'warning' }
            'ApprovalNeeded' { return 'closed_lock_with_key'}
            'Cancelled'      { return 'no_entry_sign'}
            'Denied'         { return 'x'}
        }
        return $emoji
    }

    # Translate formatted @mentions like @bod@domain.com into @devblackops
    hidden [string]_ProcessMentions([string]$Text) {
        $processed = $Text

        $mentions = $processed | Select-String -Pattern '(@\S*|@\S*)' -AllMatches | ForEach-Object {
            $_.Matches | ForEach-Object {
                [pscustomobject]@{
                    FormattedId = $_.Value
                    UnformattedId = $_.Value.TrimStart('<@').TrimEnd('>')
                }
            }
        }
        $mentions | ForEach-Object {
            if ($name = $this.UsernameToUserId($_.UnformattedId)) {
                $processed = $processed -replace $_.FormattedId, "<users/$($name)>"
                $this.LogDebug($processed)
            } else {
                $this.LogDebug([LogSeverity]::Warning, "Unable to translate @mention [$($_.FormattedId)] into a username")
            }
        }

        return $processed
    }
}