#!/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
}
#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 Compare-Timestamps {
param (
[Parameter(Mandatory=$true)]
[Int64]
$OriginServerTs
)
$current_time = (Get-Date).ToUniversalTime()
#$OriginServerTs is milliseconds since 1970-01-01 (epoch time in milliseconds)
$original_time = Get-Date -Date ((Get-Date -Date '1970-01-01') + [timespan]::FromMilliseconds($OriginServerTs))
#return the difference
Write-Output ($current_time - $original_time)
}
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
)
#the "ball" is a string returned by the bot
if($Event.content.msgtype -eq 'm.text' -and $Event.content.body -match '^!ping( (?.*))?') {
$difference = Compare-Timestamps -OriginServerTs $Event.origin_server_ts
$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
}
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 last used next_batch from account_data
$token = Get-NextBatchToken
#get /sync filter
$filter_id = Get-FilterId
Try {
#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) {
#use $token of the previous sync
Set-NextBatchToken -Token $token
$token = Invoke-MatrixSync -Token $token -FilterId $filter_id
}
}
Start-MatrixSync