Private/Protocol/MailboxClient.ps1

function New-WormholeSideId {
    [CmdletBinding()]
    param()

    $bytes = [byte[]]::new(5)
    $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create()
    try {
        $rng.GetBytes($bytes)
    }
    finally {
        $rng.Dispose()
    }

    ConvertTo-WormholeHex -Bytes $bytes
}

function Connect-WormholeMailbox {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session
    )

    Write-WormholeDebug -Component 'mailbox' -Message 'Connecting mailbox socket.' -Session $Session -Data @{ relayUrl = $Session.RelayUrl }
    $socket = Invoke-WormholeWithRetry -Action {
        Connect-WormholeWebSocket -RelayUrl $Session.RelayUrl
    }

    $Session.Socket = $socket
    $Session.Connected = $true
    Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox socket connected.' -Session $Session

    $bind = New-WormholeProtocolMessage -Type 'bind' -Fields @{
        appid = $Session.AppId
        side = $Session.Side
        id = (New-Guid).Guid
    }
    Write-WormholeDebug -Component 'mailbox' -Message 'Sending bind command.' -Session $Session -Data @{ requestId = $bind.id; appId = $Session.AppId }
    Send-WormholeWebSocketJson -Socket $Session.Socket -Message $bind

    Wait-WormholeMailboxAck -Session $Session -RequestId $bind.id | Out-Null
    Write-WormholeDebug -Component 'mailbox' -Message 'Bind acknowledged.' -Session $Session -Data @{ requestId = $bind.id }
    $Session
}

function Wait-WormholeMailboxMessage {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session,

        [Parameter()]
        [scriptblock] $Filter = { $true },

        [Parameter()]
        [int] $TimeoutSeconds = 60
    )

    if ($null -eq $Session.PSObject.Properties['PendingMessages']) {
        $Session | Add-Member -NotePropertyName PendingMessages -NotePropertyValue ([System.Collections.Generic.Queue[object]]::new())
    }

    $pendingMessages = $Session.PendingMessages
    $deferredMessages = [System.Collections.Generic.List[object]]::new()

    function Restore-WormholeDeferredMessages {
        param(
            [pscustomobject] $RestoreSession,

            [System.Collections.Generic.List[object]] $Deferred,

            [System.Collections.Generic.Queue[object]] $Pending
        )

        if ($Deferred.Count -eq 0) {
            return
        }

        $combined = [System.Collections.Generic.Queue[object]]::new()
        foreach ($item in $Deferred) {
            $combined.Enqueue($item)
        }

        foreach ($item in $Pending) {
            $combined.Enqueue($item)
        }

        $RestoreSession.PendingMessages = $combined
    }

    $deadline = [DateTimeOffset]::UtcNow.AddSeconds($TimeoutSeconds)
    Write-WormholeDebug -Component 'mailbox' -Message 'Waiting for mailbox message.' -Session $Session -Data @{ timeoutSeconds = $TimeoutSeconds }
    while ([DateTimeOffset]::UtcNow -lt $deadline) {
        $remaining = [int][Math]::Ceiling(($deadline - [DateTimeOffset]::UtcNow).TotalSeconds)
        if ($remaining -lt 1) {
            break
        }

        if ($pendingMessages.Count -eq 0) {
            $incoming = Receive-WormholeWebSocketJson -Socket $Session.Socket -TimeoutSeconds $remaining
            foreach ($item in @($incoming)) {
                if ($null -ne $item) {
                    $pendingMessages.Enqueue($item)
                }
            }

            if ($pendingMessages.Count -gt 1) {
                Write-WormholeDebug -Component 'mailbox' -Message 'Received batched mailbox messages.' -Session $Session -Data @{ count = $pendingMessages.Count }
            }
        }

        if ($pendingMessages.Count -eq 0) {
            continue
        }

        $message = $pendingMessages.Dequeue()
        $messageId = if ($null -ne $message.PSObject.Properties['id']) { [string]$message.id } else { '' }
        $messagePhase = if ($null -ne $message.PSObject.Properties['phase']) { [string]$message.phase } else { '' }

        if ($message.type -eq 'welcome') {
            $Session.Welcome = $message.welcome
            Write-WormholeDebug -Component 'mailbox' -Message 'Captured welcome message.' -Session $Session
            continue
        }

        if ($message.type -eq 'error') {
            $serverError = if ($null -ne $message.PSObject.Properties['error']) { [string]$message.error } else { 'unknown server error' }
            Write-WormholeDebug -Component 'mailbox' -Message 'Received server error message.' -Session $Session -Data @{ error = $serverError }
            throw "Mailbox server error: $serverError"
        }

        if (& $Filter $message) {
            Restore-WormholeDeferredMessages -RestoreSession $Session -Deferred $deferredMessages -Pending $pendingMessages
            Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox message matched filter.' -Session $Session -Data @{ type = [string]$message.type; id = $messageId; phase = $messagePhase }
            return $message
        }

        $deferredMessages.Add($message)
        Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox message did not match filter.' -Session $Session -Data @{ type = [string]$message.type; id = $messageId; phase = $messagePhase }
    }

    Restore-WormholeDeferredMessages -RestoreSession $Session -Deferred $deferredMessages -Pending $pendingMessages

    throw "Timed out waiting for mailbox message after $TimeoutSeconds seconds."
}

function Wait-WormholeMailboxAck {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session,

        [Parameter(Mandatory = $true)]
        [string] $RequestId,

        [Parameter()]
        [int] $TimeoutSeconds = 30
    )

    Write-WormholeDebug -Component 'mailbox' -Message 'Waiting for ACK.' -Session $Session -Data @{ requestId = $RequestId; timeoutSeconds = $TimeoutSeconds }
    Wait-WormholeMailboxMessage -Session $Session -TimeoutSeconds $TimeoutSeconds -Filter {
        param($msg)
        $msg.type -eq 'ack' -and $msg.id -eq $RequestId
    }
}

function Invoke-WormholeMailboxCommand {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session,

        [Parameter(Mandatory = $true)]
        [string] $Type,

        [Parameter()]
        [hashtable] $Fields = @{},

        [Parameter()]
        [string] $ResponseType,

        [Parameter()]
        [int] $TimeoutSeconds = 30
    )

    $requestId = (New-Guid).Guid
    $commandFields = @{}
    foreach ($key in $Fields.Keys) {
        $commandFields[$key] = $Fields[$key]
    }
    $commandFields.id = $requestId

    $message = New-WormholeProtocolMessage -Type $Type -Fields $commandFields
    Write-WormholeDebug -Component 'mailbox' -Message 'Sending mailbox command.' -Session $Session -Data @{ type = $Type; requestId = $requestId; expects = $ResponseType }
    Send-WormholeWebSocketJson -Socket $Session.Socket -Message $message

    Wait-WormholeMailboxAck -Session $Session -RequestId $requestId -TimeoutSeconds $TimeoutSeconds | Out-Null

    if ([string]::IsNullOrWhiteSpace($ResponseType)) {
        Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox command completed with ACK only.' -Session $Session -Data @{ type = $Type; requestId = $requestId }
        return $null
    }

    $response = Wait-WormholeMailboxMessage -Session $Session -TimeoutSeconds $TimeoutSeconds -Filter {
        param($msg)
        $msg.type -eq $ResponseType
    }
    Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox command received response.' -Session $Session -Data @{ type = $Type; requestId = $requestId; responseType = $ResponseType }
    $response
}

function Open-WormholeMailbox {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session,

        [Parameter()]
        [switch] $AllocateNameplate
    )

    if ($AllocateNameplate) {
        Write-WormholeDebug -Component 'mailbox' -Message 'Allocating nameplate.' -Session $Session
        $allocated = Invoke-WormholeMailboxCommand -Session $Session -Type 'allocate' -ResponseType 'allocated'

        if ($allocated -is [System.Array]) {
            $allocated = @($allocated | Where-Object { $null -ne $_ -and $_.PSObject.Properties['type'] -and [string]$_.type -eq 'allocated' } | Select-Object -First 1)
            if ($allocated.Count -gt 0) {
                $allocated = $allocated[0]
            }
            else {
                $allocated = $null
            }
        }

        $nameplate = $null
        foreach ($propertyName in @('nameplate', 'nameplate_id', 'nameplateId')) {
            if ($null -ne $allocated -and $null -ne $allocated.PSObject.Properties[$propertyName]) {
                $candidateValue = [string]$allocated.$propertyName
                if (-not [string]::IsNullOrWhiteSpace($candidateValue)) {
                    $nameplate = $candidateValue
                    break
                }
            }
        }

        if ([string]::IsNullOrWhiteSpace($nameplate) -and $null -ne $allocated -and $null -ne $allocated.PSObject.Properties['allocated']) {
            $allocatedObject = $allocated.allocated
            if ($null -ne $allocatedObject) {
                foreach ($propertyName in @('nameplate', 'nameplate_id', 'nameplateId')) {
                    if ($null -ne $allocatedObject.PSObject.Properties[$propertyName]) {
                        $candidateValue = [string]$allocatedObject.$propertyName
                        if (-not [string]::IsNullOrWhiteSpace($candidateValue)) {
                            $nameplate = $candidateValue
                            break
                        }
                    }
                }
            }
        }

        if ([string]::IsNullOrWhiteSpace($nameplate)) {
            $allocatedJson = ConvertTo-Json -InputObject $allocated -Depth 20 -Compress
            throw "Allocate response did not include nameplate. Response: $allocatedJson"
        }

        $Session.Nameplate = $nameplate
        Write-WormholeDebug -Component 'mailbox' -Message 'Allocated nameplate.' -Session $Session -Data @{ nameplate = $Session.Nameplate }
    }

    Write-WormholeDebug -Component 'mailbox' -Message 'Claiming nameplate.' -Session $Session -Data @{ nameplate = $Session.Nameplate }
    $claimed = Invoke-WormholeMailboxCommand -Session $Session -Type 'claim' -Fields @{ nameplate = $Session.Nameplate } -ResponseType 'claimed'
    if ($claimed -is [System.Array]) {
        $claimed = @($claimed | Where-Object { $null -ne $_ -and $_.PSObject.Properties['type'] -and [string]$_.type -eq 'claimed' } | Select-Object -First 1)
        if ($claimed.Count -gt 0) {
            $claimed = $claimed[0]
        }
        else {
            $claimed = $null
        }
    }

    $mailboxId = $null
    foreach ($propertyName in @('mailbox', 'mailbox_id', 'mailboxId', 'mbox')) {
        if ($null -ne $claimed -and $null -ne $claimed.PSObject.Properties[$propertyName]) {
            $candidateValue = [string]$claimed.$propertyName
            if (-not [string]::IsNullOrWhiteSpace($candidateValue)) {
                $mailboxId = $candidateValue
                break
            }
        }
    }

    if ([string]::IsNullOrWhiteSpace($mailboxId) -and $null -ne $claimed -and $null -ne $claimed.PSObject.Properties['claimed']) {
        $claimedObject = $claimed.claimed
        if ($null -ne $claimedObject) {
            foreach ($propertyName in @('mailbox', 'mailbox_id', 'mailboxId', 'mbox')) {
                if ($null -ne $claimedObject.PSObject.Properties[$propertyName]) {
                    $candidateValue = [string]$claimedObject.$propertyName
                    if (-not [string]::IsNullOrWhiteSpace($candidateValue)) {
                        $mailboxId = $candidateValue
                        break
                    }
                }
            }
        }
    }

    if ([string]::IsNullOrWhiteSpace($mailboxId)) {
        $claimedJson = ConvertTo-Json -InputObject $claimed -Depth 20 -Compress
        throw "Claim response did not include mailbox id. Response: $claimedJson"
    }

    $Session.MailboxId = $mailboxId
    Write-WormholeDebug -Component 'mailbox' -Message 'Claimed mailbox.' -Session $Session -Data @{ mailboxId = $Session.MailboxId }

    Write-WormholeDebug -Component 'mailbox' -Message 'Opening mailbox subscription.' -Session $Session
    Invoke-WormholeMailboxCommand -Session $Session -Type 'open' -Fields @{ mailbox = $Session.MailboxId } | Out-Null
    Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox open command accepted.' -Session $Session
    $Session
}

function Add-WormholeMailboxPayload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session,

        [Parameter(Mandatory = $true)]
        [string] $Phase,

        [Parameter(Mandatory = $true)]
        [byte[]] $Body
    )

    $hex = ConvertTo-WormholeHex -Bytes $Body
    Write-WormholeDebug -Component 'mailbox' -Message 'Adding mailbox payload.' -Session $Session -Data @{ phase = $Phase; bodyBytes = $Body.Length }
    Invoke-WormholeMailboxCommand -Session $Session -Type 'add' -Fields @{ phase = $Phase; body = $hex } | Out-Null
}

function Receive-WormholeMailboxPayload {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session,

        [Parameter()]
        [string] $Phase,

        [Parameter()]
        [int] $TimeoutSeconds = 300
    )

    Write-WormholeDebug -Component 'mailbox' -Message 'Waiting for mailbox payload.' -Session $Session -Data @{ phase = $Phase; timeoutSeconds = $TimeoutSeconds }
    $message = Wait-WormholeMailboxMessage -Session $Session -TimeoutSeconds $TimeoutSeconds -Filter {
        param($msg)
        if ($msg.type -ne 'message') {
            return $false
        }

        if ($msg.side -eq $Session.Side) {
            return $false
        }

        if ([string]::IsNullOrWhiteSpace($Phase)) {
            return $true
        }

        $msg.phase -eq $Phase
    }

    [pscustomobject]@{
        Side = [string]$message.side
        Phase = [string]$message.phase
        Body = ConvertFrom-WormholeHex -Hex ([string]$message.body)
        MessageId = [string]$message.id
    }
}

function Close-WormholeMailbox {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [pscustomobject] $Session,

        [Parameter()]
        [ValidateSet('happy', 'lonely', 'scary', 'errory')]
        [string] $Mood = 'happy'
    )

    if ($Session.Socket -eq $null) {
        Write-WormholeDebug -Component 'mailbox' -Message 'Close requested but no active socket.' -Session $Session
        return
    }

    Write-WormholeDebug -Component 'mailbox' -Message 'Closing mailbox.' -Session $Session -Data @{ mood = $Mood }
    try {
        if ($Session.MailboxId) {
            Invoke-WormholeMailboxCommand -Session $Session -Type 'close' -Fields @{ mailbox = $Session.MailboxId; mood = $Mood } -ResponseType 'closed' | Out-Null
            Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox closed.' -Session $Session
        }
    }
    catch {
    }

    try {
        if ($Session.Nameplate) {
            Invoke-WormholeMailboxCommand -Session $Session -Type 'release' -Fields @{ nameplate = $Session.Nameplate } -ResponseType 'released' | Out-Null
            Write-WormholeDebug -Component 'mailbox' -Message 'Nameplate released.' -Session $Session
        }
    }
    catch {
    }

    Disconnect-WormholeWebSocket -Socket $Session.Socket
    $Session.Socket = $null
    $Session.Connected = $false
    Write-WormholeDebug -Component 'mailbox' -Message 'Mailbox disconnect complete.' -Session $Session
}