PlugEvents.psm1

function Connect-PlugEvents {
    <#
    .SYNOPSIS
        Connect to Plug.Events
 
    .DESCRIPTION
        Connect to Plug.Events
 
    .PARAMETER Endpoint
        Endpoint to connect to. When not entered it will retrieve the first production endpoint by default.
 
    .PARAMETER ConnectionToken
        Token to use in the connection. When not entered it will retrieve this automatically.
 
    .EXAMPLE
        Connect to Plug-Events
 
        PS> Connect-PlugEvents
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Its the name of the product')]

    [CmdLetBinding()]
    Param (
        [Parameter(Mandatory = $false, Position = 1)]
        [String] $Endpoint = (Get-PlugEventsEndpoint -Type p -First 1).Types.Endpoint,

        [Parameter(Mandatory = $false, Position = 2)]
        [String] $ConnectionToken = (Open-PlugEventsWebsocket -Endpoint $Endpoint).connectionToken
    )

    # Create the websocket client and cancellation token
    $Script:websocket = [System.Net.WebSockets.ClientWebSocket]::new()
    $Script:cancellationToken = [System.Threading.CancellationTokenSource]::new()

    # Add the option for json
    $Script:websocket.Options.AddSubProtocol('json')

    # Connect
    $uriObj = [Uri]"wss://$Endpoint/hub1?id=$ConnectionToken"
    $null = $Script:websocket.ConnectAsync($uriObj, $Script:cancellationToken.Token).GetAwaiter().GetResult()

    # Send a message to establish the handshake
    Send-PlugEventsMessage -Message '{"protocol":"json","version":1}'

    # Check for a message
    $message = Receive-PlugEventsMessage -IgnoreKeepAlive
    if($message -ne "{}") {
        throw "Plug Events: the connection could not be established. Error message: $message"
    }
}

function Disconnect-PlugEvents {
    <#
    .SYNOPSIS
        Disconnect from Plug.Events
 
    .DESCRIPTION
        Disconnect from Plug.Events
 
    .EXAMPLE
        Disconnect
 
        PS> Disconnect-PlugEvents
    #>


    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', Justification = 'Its the name of the product')]

    [CmdLetBinding()]
    Param ()

    # Close the connection
    $null = $Script:websocket.CloseAsync(
        [System.Net.WebSockets.WebSocketCloseStatus]::Empty,
        "",
        $Script:cancellationToken.Token
    )

    # Dispose of the objects
    $Script:websocket.Dispose()
    $Script:cancellationToken.Dispose()
}

function Get-PlugEventsCancellationToken {
    <#
    .SYNOPSIS
        Get the CancellationToken for the connection to Plug.Events
 
    .DESCRIPTION
        Get the CancellationToken for the connection to Plug.Events
 
    .EXAMPLE
        Get the token
 
        PS> Get-PlugEventsCancellationToken
    #>


    [CmdLetBinding()]
    Param ()

    # Return the cancellationtoken object
    $Script:cancellationToken
}

function Get-PlugEventsConnection {
    <#
    .SYNOPSIS
        Get the object that contains the websocketconnection to plug.events
 
    .DESCRIPTION
        Get the object that contains the websocketconnection to plug.events
 
    .EXAMPLE
        Get the connection
 
        PS> Get-PlugEventsConnection
    #>


    [CmdLetBinding()]
    Param ()

    # Return the websocket object
    $Script:websocket
}

function Get-PlugEventsEndpoint {
    <#
    .SYNOPSIS
        Return the endpoints for plug.events backend servers.
 
    .DESCRIPTION
        Return the endpoints for plug.events backend servers.
 
    .PARAMETER Type
        Type of the server. Can be p or i.
 
    .PARAMETER First
        Amount of entries to return.
 
    .EXAMPLE
        Get all endpoints
 
        PS> Get-PlugEventsEndpoint
        Id Types
        -- -----
        639009792141241401 {@{Type=p; Endpoint=pi31.plug.events}, @{Type=i; Endpoint=ii31.plug.events}}
        639003195191559039 {@{Type=p; Endpoint=pi30.plug.events}, @{Type=i; Endpoint=ii30.plug.events}}
 
    .EXAMPLE
        Get first endpoint
 
        PS> Get-PlugEventsEndpoint -First 1
        Id Types
        -- -----
        639009792141241401 {@{Type=p; Endpoint=pi31.plug.events}, @{Type=i; Endpoint=ii31.plug.events}}
 
    .EXAMPLE
        Get first endpoint of type p
 
        PS> Get-PlugEventsEndpoint -Type p -First 1
        Id Types
        -- -----
        639009792141241401 {@{Type=p; Endpoint=pi31.plug.events}}
    #>


    [CmdLetBinding()]
    [OutputType([Array])]

    Param (
        [Parameter(Mandatory = $false, Position = 1)]
        [String] $Type = "none",

        [Parameter(Mandatory = $false, Position = 2)]
        [Int] $First = 0
    )

    # Get all the endpoints from the txt file
    $nodemap = Invoke-RestMethod -uri "https://www.plug.events/nodemap.txt" -Method GET

    # Parse the endpoints
    $endpoints = @()
    $nodemap | ConvertFrom-Csv -Header "id", "endpoints" | ForEach-Object {
        # Create an object
        $node = [PSCustomObject]@{
            Id = $_.Id
            Types = @()
        }

        # Split the different types
        foreach ($endpoint in $_.endpoints.split("|")) {
            # Check for the type
            if($Type -ne "none") {
                if($Type -ne $endpoint[0]) {
                    Continue
                }
            }

            $node.Types += [PSCustomObject]@{
                Type = $endpoint[0]
                Endpoint = $endpoint
            }
        }

        # Add the node to the endpoint array
        $endpoints += $node
    }

    # Filter amount
    if($First -gt 0) {
        $endpoints = $endpoints | Select-Object -First $First
    }

    $endpoints
}

function Get-PlugEventsUmbrellaEvent {
    <#
    .SYNOPSIS
        Get all events under a specific umbrella in Plug.Events
 
    .DESCRIPTION
        Get all events under a specific umbrella in Plug.Events
 
    .PARAMETER Id
        Id of the umbrella org
 
    .PARAMETER StartDate
        DateTime object containing the date to start filtering from. Default is 30 days in the past.
 
    .PARAMETER EndDate
        DateTime object containing the date to stop filtering from. Default is today.
 
    .PARAMETER Top
        Maximum amount of items to return. Default is 999.
 
    .EXAMPLE
        Get the events
 
        PS> Get-PlugEventsUmbrellaEvent -Id "balfolk-nl" -StartDate (Get-Date "2025-01-01") -EndDate (Get-Date "2025-12-31") -Top 200
    #>


    [CmdLetBinding()]
    [OutputType([Array])]
    Param (
        [Parameter(Mandatory = $true, Position = 1)]
        [String] $Id,

        [Parameter(Mandatory = $false, Position = 2)]
        [DateTime] $StartDate = (Get-Date).AddDays(-30),

        [Parameter(Mandatory = $false, Position = 3)]
        [DateTime] $EndDate = (Get-Date),

        [Parameter(Mandatory = $false, Position = 4)]
        [Int] $Top = 999
    )
    # Set up the message
    $message = '{"target":"GetNetworkViewPage2","arguments":[{"recKind":1,"slug":"'+$Id+'","slugs":null,"direction":103,"startAt":0,"maxCount":'+$Top+',"nameContains":"","roleSlugFilters":null,"isClaimed":null,"seq1Filter":null,"seq1InverseFilter":null,"minEventTime":"'+($StartDate | Get-Date -Format "yyyyMMdd0000")+'","maxEventTime":"'+($EndDate | Get-Date -Format "yyyyMMdd0000")+'","interestSlug":null,"subinterest":null,"localeSlug":null,"toRoleNameContains":""}],"invocationId":"9","type":1}'

    # Send the message
    Send-PlugEventsMessage -Message $message

    # Receive the response
    $response = Receive-PlugEventsMessage -Timeout 30 -IgnoreKeepAlive

    # Convert the response from JSON
    ($response | ConvertFrom-Json).result.directionSets.items
}

function Open-PlugEventsWebsocket {
    <#
    .SYNOPSIS
        Open a websocket connection to plug.events and return the connectionToken.
 
    .DESCRIPTION
        Open a websocket connection to plug.events and return the connectionToken.
 
    .PARAMETER Endpoint
        Endpoint to connect to. When not entered it will retrieve the first production endpoint by default.
 
    .EXAMPLE
        Open the connection
 
        PS> Open-PlugEventsWebsocket
        negotiateVersion connectionId connectionToken availableTransports
        ---------------- ------------ --------------- -------------------
                       1 abcdefabcdefabcdefabcd abcdefabcdefabcdefabcd {@{transport=WebSockets; transferFormats=System.Object[]}, @{transport=ServerSentEvents; transferFormats=System…
 
    .EXAMPLE
        Open the connection for a specific endpoint
 
        PS> Open-PlugEventsWebsocket -Endpoint "pi31.plug.events"
        negotiateVersion connectionId connectionToken availableTransports
        ---------------- ------------ --------------- -------------------
                       1 abcdefabcdefabcdefabcd abcdefabcdefabcdefabcd {@{transport=WebSockets; transferFormats=System.Object[]}, @{transport=ServerSentEvents; transferFormats=System…
 
    #>


    [CmdLetBinding()]
    [OutputType([Object])]

    Param (
        [Parameter(Mandatory = $false, Position = 1)]
        [String] $Endpoint = (Get-PlugEventsEndpoint -Type p -First 1).Types.Endpoint
    )

    # Open the connection
    Invoke-RestMethod -Uri "https://$Endpoint/hub1/negotiate?negotiateVersion=1" -Method POST
}

function Receive-PlugEventsMessage {
    <#
    .SYNOPSIS
        Receive a message from the plug.events back-end.
 
    .DESCRIPTION
        Receive a message from the plug.events back-end. The end-of-message marker will automatically be removed from the output.
 
    .PARAMETER Connection
        Connection object for the websocket connection. Default this will be the connection set up via Connect-PlugEvents
 
    .PARAMETER Timeout
        Maximum time in seconds to wait for a response before cancelling the request (this will close the connection). Default is 21 seconds.
 
    .PARAMETER IgnoreKeepAlive
        Ignore the keepalive messages and only return other types of messages.
 
    .EXAMPLE
        Receive the data
 
        PS> Receive-PlugEventsMessage -Timeout 30 -IgnoreKeepAlive
    #>


    [CmdLetBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $false, Position = 1)]
        [Object] $Connection = $Script:websocket,

        [Parameter(Mandatory = $false, Position = 2)]
        [Int] $Timeout = 21,

        [Parameter(Mandatory = $false, Position = 3)]
        [Switch] $IgnoreKeepAlive
    )
    # Check if a connection is create
    if($Connection.State -ne [System.Net.WebSockets.WebSocketState]::Open) {
        throw "Plug-Events: no open connection was detected. Please run Connect-PlugEvents first."
    }

    # Create a buffer to store the message
    $buffer        = New-Object byte[] 8192
    $stringbuilder = [System.Text.StringBuilder]::new()

    # Extra variable to prevent unending loop
    $time = Get-Date

    while ($Connection.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
        # Create an async call
        $cancellationToken = [System.Threading.CancellationTokenSource]::new($Timeout*1000)
        $await = $Connection.ReceiveAsync([ArraySegment[byte]]::new($buffer), $cancellationToken.Token)

        # Wait untill the response is returned
        while($await.Status -ne "RanToCompletion") {
            # Check if the timeout has been reached
            if ($await.Status -eq "Canceled") {
                throw "Plug-Events: the timeout of $Timeout seconds has been exceeded while waiting for a response from the server."
            }

            # Check if not in unending loop
            if((Get-Date) -gt $time.AddSeconds($Timeout)) {
                $null = $cancellationToken.Cancel()
                throw "Plug-Events: waiting for more then $Timeout seconds for a reply of server. request has been cancelled."
            }
        }

        # Get the result from the await
        $result = $await.GetAwaiter().GetResult()

        if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) {
            Disconnect-PlugEvents
            break
        }

        # Accumulate chunk(s) until EndOfMessage
        $null = $stringbuilder.Append([System.Text.Encoding]::UTF8.GetString($buffer, 0, $result.Count))

        # If the message is done convert the stringbuilder to a string and stop the loop
        if ($result.EndOfMessage) {
            $text = $stringbuilder.ToString()

            # Check if the message is a keepalive
            if ($IgnoreKeepAlive -and $text -eq '{"type":6}') {
                # Continue and clear the stringbuilder
                $null = $stringbuilder.Clear()
            }
            else {
                # Break the loop
                break
            }
        }
    }

    # Check if the end of message character is still added
    if([int]$text[-1] -eq 30) {
        $text = $text.TrimEnd([char]0x1e)
    }

    # Return the message
    $text
}

function Send-PlugEventsMessage {
    <#
    .SYNOPSIS
        Send a message to the plug.events back-end.
 
    .DESCRIPTION
        Send a message to the plug.events back-end.
 
    .PARAMETER Message
        The message to send in string format. If the end-of-message marker is not added this will be added automatically.
 
    .PARAMETER Connection
        Connection object for the websocket connection. Default this will be the connection set up via Connect-PlugEvents
 
    .PARAMETER Timeout
        Maximum time in seconds to wait for a response before cancelling the request (this will close the connection). Default is 5 seconds.
 
    .PARAMETER Async
        Dont wait on confirmation that the message has been send.
 
    .EXAMPLE
        Send the message
 
        PS> $Message = '{"target":"GetLocalesBySlug","arguments":[["netherlands"]],"invocationId":"1","type":1}'
        PS> Send-PlugEventsMessage -Message $Message -Timeout 10 -Async
    #>


    [CmdLetBinding()]
    [OutputType([String])]
    Param (
        [Parameter(Mandatory = $true, Position = 1)]
        [String] $Message,

        [Parameter(Mandatory = $false, Position = 2)]
        [Object] $Connection = $Script:websocket,

        [Parameter(Mandatory = $false, Position = 3)]
        [Int] $Timeout = 5,

        [Parameter(Mandatory = $false, Position = 4)]
        [Switch] $Async
    )
    # Check if a connection is create
    if ($Connection.State -ne [System.Net.WebSockets.WebSocketState]::Open) {
        throw "Plug-Events: no open connection was detected. Please run Connect-PlugEvents first."
    }

    # Check if end of message marker is added (0x1e = 30)
    if ([int]$Message[-1] -ne 30) {
        $Message += [char]0x1e
    }

    # Create byte array
    $bytes = [System.Text.Encoding]::UTF8.GetBytes($Message)
    $seg = [ArraySegment[byte]]::new($bytes)

    # Send the message
    $cancellationToken = [System.Threading.CancellationTokenSource]::new($Timeout * 1000)
    $await = $Connection.SendAsync(
        $seg,
        [System.Net.WebSockets.WebSocketMessageType]::Text,
        $true,
        $cancellationToken.Token
    )

    if ( -not $Async) {
        # Extra variable to prevent unending loop
        $time = Get-Date

        # Wait untill the message is semd
        while ($await.Status -ne "RanToCompletion") {
            # Check if the timeout has been reached
            if ($await.Status -eq "Canceled") {
                throw "Plug-Events: the timeout of $Timeout seconds has been exceeded while waiting for a response from the server."
            }

            # Check if not in unending loop
            if ((Get-Date) -gt $time.AddSeconds($Timeout)) {
                $null = $cancellationToken.Cancel()
                throw "Plug-Events: waiting for more then $Timeout seconds for a reply of server. request has been cancelled."
            }
        }
    }
}

# ===================================================================
# ================== WEBSOCKET ======================================
# ===================================================================

# Create env variables
$Script:websocket = [System.Net.WebSockets.ClientWebSocket]::new()
$Script:cancellationToken = [System.Threading.CancellationTokenSource]::new()

# ===================================================================
# ================== TELEMETRY ======================================
# ===================================================================

# Create env variables
$Env:PLUGEVENTS_TELEMETRY_OPTIN = (-not $Evn:POWERSHELL_TELEMETRY_OPTOUT) # use the invert of default powershell telemetry setting

# Set up the telemetry
Initialize-THTelemetry -ModuleName "plugEvents"
Set-THTelemetryConfiguration -ModuleName "plugEvents" -OptInVariableName "PLUGEVENTS_TELEMETRY_OPTIN" -StripPersonallyIdentifiableInformation $true -Confirm:$false
Add-THAppInsightsConnectionString -ModuleName "plugEvents" -ConnectionString "InstrumentationKey=df9757a1-873b-41c6-b4a2-2b93d15c9fb1;IngestionEndpoint=https://westeurope-5.in.applicationinsights.azure.com/;LiveEndpoint=https://westeurope.livediagnostics.monitor.azure.com/"

# Create a message about the telemetry
Write-Information ("Telemetry for plugEvents module is $(if([string] $Env:PLUGEVENTS_TELEMETRY_OPTIN -in ("no","false","0")){"NOT "})enabled. Change the behavior by setting the value of " + '$Env:PLUGEVENTS_TELEMETRY_OPTIN') -InformationAction Continue

# Send a metric for the installation of the module
Send-THEvent -ModuleName "plugEvents" -EventName "Import Module plugEvents"