#!/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)] [int64] $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