#!/usr/bin/pwsh
#alternatively read params from $env
#for more flexible Dockerfile support
param (
    [ValidateNotNullOrEmpty()]
    [uri]
    $HomeServer = $env:HOMESERVER,
    [ValidateNotNullOrEmpty()]
    [string]
    $UserId = $env:USERID,
    [ValidateNotNullOrEmpty()]
    [securestring]
    $AccessToken = ((Get-Content -Path $env:ACCESSTOKEN_FILE -Raw).Trim() `
                    | ConvertTo-SecureString -AsPlainText -Force),
    [switch]
    $Unencrypted = $false #is alternatively set with $env:UNENCRYPTED
)
$ErrorActionPreference = 'Stop'
if($env:UNENCRYPTED -eq 'TRUE') {
    $Unencrypted = $true
}
#$join_time contains rooms we joined during runtime
#it is used to ignore events sent before we joined
$join_time = @{}
#used in Invoke-RestMethod
$authentication_headers = @{
    Authentication = 'Bearer'
    Token = $AccessToken
    ContentType = 'application/json'
    AllowUnencryptedAuthentication = $Unencrypted
}
$account_data_type_prefix = 'de.lubiland.pingposh.'
$account_data_types = @{
    next_batch = $account_data_type_prefix+'next_batch'
}
function Send-MatrixEvent {
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $RoomId,
        [Parameter(Mandatory=$true)]
        [string]
        $Event,
        [Parameter(Mandatory=$true)]
        [string]
        $EventType
    )
    #txn_id should be unique per client, so we use timestamp+random
    $txn_id = '{0}{1}' -f (Get-Date -UFormat '%s'),(Get-Random)
    $uri = '{0}_matrix/client/r0/rooms/{1}/send/{2}/{3}' -f $HomeServer,$RoomId,$EventType,$txn_id
    $http_splat = @{
        Uri = $uri
        Method = 'Put'
        Body = $Event
    }
    $response = Invoke-RestMethod @authentication_headers @http_splat
    if($response.event_id) {
        Write-Host ('Event {0} sent to room {1}' -f $response.event_id,$RoomId)
    }
}
function Send-Pong {
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $RoomId,
        [Parameter(Mandatory=$true)]
        [string]
        $Body,
        [Parameter(Mandatory=$false)]
        [string]
        $FormattedBody,
        [Parameter(Mandatory=$true)]
        [string]
        $OriginHomeServer,
        [Parameter(Mandatory=$true)]
        [int]
        $Duration,
        
        [Parameter(Mandatory=$true)]
        [string]
        $PingEventId
    )
    $event = @{
        msgtype = 'm.notice'
        body = $Body
        pong = @{
            from = $OriginHomeServer
            ms = $Duration
            ping = $PingEventId
        }
    }
    if($FormattedBody) {
        $event += @{
            format = 'org.matrix.custom.html'
            formatted_body = $FormattedBody
        }
    }
    $event_json = $event | ConvertTo-Json -Compress
    Send-MatrixEvent -RoomId $RoomId -Event $event_json -EventType 'm.room.message'
}
function ConvertFrom-MatrixTimestamp {
    param (
        [Parameter(Mandatory)]
        [Int64]
        $OriginServerTs
    )
    #$OriginServerTs is milliseconds since 1970-01-01 (epoch time in milliseconds)
    return Get-Date -Date ((Get-Date -Date '1970-01-01') + [timespan]::FromMilliseconds($OriginServerTs))
}
function Measure-TimeDifference {
    param (
        [Parameter(Mandatory=$true)]
        [datetime]
        $OriginTime,
        [datetime]
        $ReferenceTime = (Get-Date).ToUniversalTime()
    )
    #return the difference
    Write-Output ($ReferenceTime - $OriginTime)
}
function ConvertTo-HumanReadableTimespan {
    param (
        [parameter(Mandatory=$true)]
        [timespan]
        $TimeSpan
    )
    switch($TimeSpan) {
        Default {
            $output = '{0} days' -f [System.Math]::Round($TimeSpan.TotalDays, 2)
        }
        {$PSItem.TotalHours -lt 24} {
            $output = '{0} h' -f [System.Math]::Round($TimeSpan.TotalHours, 2)
        }
        {$PSItem.TotalMinutes -lt 120} {
            $output = '{0} min' -f [System.Math]::Round($TimeSpan.TotalMinutes, 2)
        }
        {$PSItem.TotalSeconds -lt 120} {
            $output = '{0} s' -f [System.Math]::Round($TimeSpan.TotalSeconds, 2)
        }
        {$PSItem.TotalMilliseconds -lt 10000} {
            $output = '{0} ms' -f [System.Math]::Round($TimeSpan.TotalMilliseconds, 0)
        }
    }
    Write-Output $output
}
function Join-Pong {
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $RoomId,
        [Parameter(Mandatory=$true)]
        [string]
        $PingEventId,
        [Parameter(Mandatory=$true)]
        [string]
        $SenderMxId,
        [Parameter(Mandatory=$true)]
        [string]
        $ReadableTimespan,
        [Parameter(Mandatory=$false)]
        [string]
        $Ball
    )
    if($Ball) {
        $padded_ball = '"{0}" ' -f $Ball
    }
    $body = '{0}: Pong! (ping {1}took {2} to arrive)' -f $SenderMxId,$padded_ball,$ReadableTimespan
    $formatted_body  = '{0}: Pong! ' -f $SenderMxId
    $formatted_body += '(ping {2}took {3} to arrive)' -f $RoomId,$PingEventId,$padded_ball,$ReadableTimespan
    return @{
        Body = $body
        FormattedBody = $formatted_body
    }
}
function Open-JoinEvent {
    param (
        [Parameter(Mandatory=$true)]
        $Event,
        [Parameter(Mandatory=$true)]
        $RoomId
    )
    $origin_time = ConvertFrom-MatrixTimestamp -OriginServerTs $Event.origin_server_ts
    #check if a $join_time for $RoomId exists and if it newer than our event
    if($join_time.$RoomId) {
        if((Measure-TimeDifference -OriginTime $origin_time -ReferenceTime $join_time.$RoomId) -gt 0) {
            #ignore events sent before we joined the room
            return
        } else {
            #first event with negative difference received
            #probably all future events are after our join time
            $join_time.Remove($RoomId)
        }
    }
    #the "ball" is a string returned by the bot
    if($Event.content.msgtype -eq 'm.text' -and $Event.content.body -match '^!ping($| (?.*))') {
        $difference = Measure-TimeDifference -OriginTime $origin_time
        $readable_timespan = ConvertTo-HumanReadableTimespan -TimeSpan $difference
        #$bodies contains a hashtable with keys Body and FormattedBody
        $bodies = Join-Pong -RoomId $RoomId -PingEventId $Event.event_id -SenderMxId $Event.sender -ReadableTimespan $readable_timespan -Ball $Matches.ball
        $origin_homeserver = $Event.sender.Split(':')[1]
        Send-Pong -RoomId $RoomId @bodies -OriginHomeServer $origin_homeserver -Duration $difference.TotalMilliseconds -PingEventId $Event.event_id
    }
}
function Join-MatrixRoom {
    param (
        [Parameter(Mandatory=$true)]
        $RoomId
    )
    $uri = '{0}_matrix/client/r0/rooms/{1}/join' -f $HomeServer,$RoomId
    $http_splat = @{
        Uri = $uri
        Method = 'Post'
    }
    $response = Invoke-RestMethod @authentication_headers @http_splat
    if($response.room_id) {
        Write-Host ('Joined room {0}' -f $RoomId)
    } else {
        Write-Error ('Error joining room {0}: {1}' -f $RoomId,$response.error)
    }
}
function Get-MatrixAccountData {
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $Type
    )
    $uri = '{0}_matrix/client/r0/user/{1}/account_data/{2}' -f $HomeServer,$UserId,$Type
    $http_splat = @{
        Uri = $uri
        Method = 'Get'
    }
    $response = Invoke-RestMethod @authentication_headers @http_splat
    return $response
}
function Set-MatrixAccountData {
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $Type,
        [Parameter(Mandatory=$true)]
        [System.Object]
        $Content
    )
    $uri = '{0}_matrix/client/r0/user/{1}/account_data/{2}' -f $HomeServer,$UserId,$Type
    $http_splat = @{
        Uri = $uri
        Method = 'Put'
        Body = $Content | ConvertTo-Json -Compress
    }
    Invoke-RestMethod @authentication_headers @http_splat | Out-Null
}
function Get-NextBatchToken {
    $account_data = Get-MatrixAccountData -Type $account_data_types.next_batch
    return $account_data.next_batch
}
function Set-NextBatchToken {
    param (
        [Parameter(Mandatory=$true)]
        [string]
        $Token
    )
    Set-MatrixAccountData -Type $account_data_types.next_batch -Content @{next_batch = $Token}
}
function Invoke-MatrixSync {
    param (
        [string]
        $Token,
        [string]
        $FilterId,
        [int]
        $Timeout = 30000
    )
    $uri = '{0}_matrix/client/r0/sync?timeout={1}' -f $HomeServer,$Timeout
    if($Token) {
        $uri += '&since={0}' -f $Token
    }
    if($FilterId) {
        $uri += '&filter={0}' -f $FilterId
    }
    $http_splat = @{
        Uri = $uri
    }
    $response = Invoke-RestMethod @authentication_headers @http_splat
    #.PSObject.Properties because the rooms under .join are [NoteProperty]
    $room_ids = $response.rooms.join.PSObject.Properties.Name
    foreach($room_id in $room_ids) {
        $events = $response.rooms.join.$room_id.timeline.events
        foreach($event in $events) {
            Open-JoinEvent -Event $event -RoomId $room_id
        }
    }
    #.PSObject.Properties because the rooms under .invite are [NoteProperty]
    $room_ids = $response.rooms.invite.PSObject.Properties.Name
    foreach($room_id in $room_ids) {
        Join-MatrixRoom -RoomId $room_id
        $join_time.$room_id = (Get-Date).ToUniversalTime()
    }
    return $response.next_batch
}
function Get-MatrixFilterId {
    param (
        [System.Object]
        $Filter
    )
    $uri = '{0}_matrix/client/r0/user/{1}/filter' -f $HomeServer,$UserId
    $http_splat = @{
        Uri = $uri
        Method = 'Post'
        #a filter can be up to 3 layers deep according to spec
        #https://matrix.org/docs/spec/client_server/r0.6.0#filtering
        Body = $Filter | ConvertTo-Json -Compress -Depth 3
    }
    $response = Invoke-RestMethod @authentication_headers @http_splat
    return $response.filter_id
}
function Get-FilterId {
    $filter = @{
        event_fields = @(
            #all fields used in Open-JoinEvent
            'event_id'
            'sender'
            'origin_server_ts'
            'content.msgtype'
            'content.body'
            #'content.membership' #invites only
        )
        presence = @{
            #we don't use presence information
            types = @('invalid')
        }
        account_data = @{
            #we only retrieve account_data via the account_data api endpoint
            types = @('invalid')
        }
        room = @{
            ephemeral = @{
                #we don't use ephemeral events like typing or read receipts
                types = @('invalid')
            }
            state = @{
                types = @(
                    #to receive invites
                    'm.room.member'
                )
                lazy_load_members = $true
            }
            timeline = @{
                #exclude ourself
                not_senders = @(
                    $UserId
                )
                types = @(
                    #to receive !ping commands
                    'm.room.message'
                )
            }
            account_data = @{
                #we don't use room specific account_data
                types = @('invalid')
            }
        }
    }
    $filter_id = Get-MatrixFilterId -Filter $filter
    return $filter_id
}
function Start-MatrixSync {
    #get /sync filter
    $filter_id = Get-FilterId
    Try {
        #get last used next_batch from account_data
        $token = Get-NextBatchToken
        #try first sync with the old token
        $token = Invoke-MatrixSync -Token $token -FilterId $filter_id
    } Catch {
        #if it fails start a fresh sync reset $token
        $token = $null
    }
    while($true) {
        $token = Invoke-MatrixSync -Token $token -FilterId $filter_id
        #use $token of the previous sync
        Set-NextBatchToken -Token $token
    }
}
Start-MatrixSync