Skip to content

Instantly share code, notes, and snippets.

@scooterpsu
Last active June 18, 2025 08:44
Show Gist options
  • Select an option

  • Save scooterpsu/d30f55a6efa69b30fc7415644fbe7658 to your computer and use it in GitHub Desktop.

Select an option

Save scooterpsu/d30f55a6efa69b30fc7415644fbe7658 to your computer and use it in GitHub Desktop.
Gotify client for Windows Notifications
$domain = "p.domain.com"
$token = "AAAAAAAA"
$useHeaders = $false
. .\config.ps1
$script:sentNotifications = @()
Function Log($text){
Out-File -Append -encoding UTF8 -FilePath .\log.txt -InputObject $((get-date).ToString('T')+": "+$text.Trim().Replace("[char]39","'"))
}
Function Output($text){
$jsonData = convertfrom-json $text
$msgid = $jsonData.id
if($script:sentNotifications.Contains($msgid)){
Log("Notification already sent, skipping")
Return
}
$script:sentNotifications += $msgid
$jsonData.message = $jsonData.message.Replace("'","[char]39")
$jsonData.title = $jsonData.title.Replace("'","[char]39")
if($jsonData.message.Contains("base64")){
Log("Converting base64 encoded image")
$messageText = ($jsonData.message -split [regex]::Escape("![]("))[0].Trim()
$imageString = ($jsonData.message -split [regex]::Escape("![]("))[1].Split(")")[0]
$encodedImage = $imageString.Split(',')[1]
$decoded = [System.Convert]::FromBase64CharArray($encodedImage, 0, $encodedImage.Length)
$cacheImage = ".\cache\"+$msgid+".jpg"
Set-Content -Path $cacheImage -Value $decoded -AsByteStream
$jsonData.message = $messageText + " ![](" + $cacheImage + ")"
}
$text = ConvertTo-Json $jsonData -Compress -Depth 3
Log($text)
$cmd = "'$text' | .\toast.ps1"
$encodedcmd = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($cmd))
$process = start-process pwsh -ArgumentList "-noexit -encodedcommand $encodedcmd" -PassThru -NoNewWindow
}
Function Init-Websocket(){
$recv_queue = New-Object 'System.Collections.Concurrent.ConcurrentQueue[String]'
$ws = New-Object Net.WebSockets.ClientWebSocket
$cts = New-Object Threading.CancellationTokenSource
$ct = New-Object Threading.CancellationToken($false)
Log("Connecting...")
$connectTask = $ws.ConnectAsync("wss://$domain/stream?token=$token", $cts.Token)
do { Sleep(1) }
until ($connectTask.IsCompleted)
Log("Connected!")
$recv_job = {
param($ws, $recv_queue)
$buffer = [Net.WebSockets.WebSocket]::CreateClientBuffer(1024,1024)
$ct = [Threading.CancellationToken]::new($false)
$taskResult = $null
while ($ws.State -eq [Net.WebSockets.WebSocketState]::Open) {
$jsonResult = ""
do {
$taskResult = $ws.ReceiveAsync($buffer, $ct)
while (-not $taskResult.IsCompleted -and $ws.State -eq [Net.WebSockets.WebSocketState]::Open) {
[Threading.Thread]::Sleep(10)
}
$jsonResult += [Text.Encoding]::UTF8.GetString($buffer, 0, $taskResult.Result.Count)
} until (
$ws.State -ne [Net.WebSockets.WebSocketState]::Open -or $taskResult.Result.EndOfMessage
)
if (-not [string]::IsNullOrEmpty($jsonResult)) {
$recv_queue.Enqueue($jsonResult)
}
}
}
Log("Starting recv runspace")
$recv_runspace = [PowerShell]::Create()
$recv_runspace.AddScript($recv_job).
AddParameter("ws", $ws).
AddParameter("recv_queue", $recv_queue).BeginInvoke() | Out-Null
try {
do {
Start-Sleep -Milli 500
$msg = $null
while ($recv_queue.TryDequeue([ref] $msg)) {
if ($msg.StartsWith('{')){
Output($msg)
}else{
Log("Response: "+$msg)
}
}
} until ($ws.State -ne [Net.WebSockets.WebSocketState]::Open)
}
finally {
Log("Closing WS connection")
$closetask = $ws.CloseAsync(
[System.Net.WebSockets.WebSocketCloseStatus]::Empty,
"",
$ct
)
do { Sleep(1) }
until ($closetask.IsCompleted)
$ws.Dispose()
Log("Stopping runspace")
$recv_runspace.Stop()
$recv_runspace.Dispose()
Log("Reconnecting")
Poll-Server
Init-Websocket
}
}
Function Poll-Server(){
Log("Polling for missed notifications")
Invoke-WebRequest "https://$domain/message?token=$token" -SessionVariable session -ContentType "application/json; charset=utf-8" | %{ convertfrom-json $_.Content } | where-object {$_.messages.length -gt 0} | select -expand messages | %{ Output(ConvertTo-JSON -Compress -Depth 3 $_) }
}
if(Test-Path -Path ".\log.txt" -PathType Leaf){
Move-Item -Path ".\log.txt" -Destination ".\log.txt.old" -Force
}
if(!(Test-Path -Path ".\cache\" -PathType Leaf)){
New-Item -Path ".\cache\" -ItemType Directory -Force | Out-Null
}
Poll-Server
Init-Websocket
. .\config.ps1
$jsonData = convertfrom-json $input
$appid = $jsonData.appid
$msgid = $jsonData.id
$msgdate = $jsonData.date
$markdown = $false
$BigImage = $null
$clickurl = $null
if(Get-Member -inputobject $jsonData -name "extras"){
if(Get-Member -inputobject $jsonData.extras -name "client::notification"){
if(Get-Member -inputobject $jsonData.extras."client::notification" -name "bigImageUrl"){
$bigImageUrl = $jsonData.extras."client::notification".bigImageUrl
$BigImage = New-BTImage -Source $bigImageUrl
}
if(Get-Member -inputobject $jsonData.extras."client::notification" -name "click"){
if(Get-Member -inputobject $jsonData.extras."client::notification".click -name "url"){
$clickurl = $jsonData.extras."client::notification".click.url
}
}
}
if(Get-Member -inputobject $jsonData.extras -name "client::display"){
if(Get-Member -inputobject $jsonData.extras."client::display" -name "contentType"){
if($jsonData.extras."client::display".contentType -eq "text/markdown"){
$markdown = $true
}
}
}
}
Invoke-WebRequest https://$domain/application?token=$token |
%{ (convertfrom-json $_.content) } | where-object {$_.id -eq $appid} |
%{
$appname = $_.name
$icon = $_.image
}
if($useHeaders){
$Header = New-BTHeader -Id $appid -Title $appname
}
$AppLogo = New-BTImage -Source https://$domain/$icon -AppLogoOverride
$jsonData.title = $jsonData.title.Replace("[char]39","'")
$jsonData.message = $jsonData.message.Replace("[char]39","'")
$TextHeading = New-BTText -Text $jsonData.title
if (($markdown) -and ($jsonData.message.Contains("![]("))){
$messageText = ($jsonData.message -split [regex]::Escape("![]("))[0].Trim()
$TextBody = New-BTText -Text $messageText
$imageString = ($jsonData.message -split [regex]::Escape("![]("))[1].Split(")")[0]
$BigImage = New-BTImage -Source $imageString
}else{
$TextBody = New-BTText -Text $jsonData.message
}
if ($BigImage) {
$Binding = New-BTBinding -Children $TextHeading, $TextBody, $BigImage -AppLogoOverride $AppLogo
}else{
$Binding = New-BTBinding -Children $TextHeading, $TextBody -AppLogoOverride $AppLogo
}
$Visual = New-BTVisual -BindingGeneric $Binding
if($clickurl){
$UrlButton = New-BTButton -Content 'Details' -Arguments $clickurl
$Actions = New-BTAction -Buttons $UrlButton
$Content = New-BTContent -Visual $Visual -Header $Header -CustomTimestamp $msgdate -Actions $Actions
}else{
$Content = New-BTContent -Visual $Visual -Header $Header -CustomTimestamp $msgdate
}
$Dismissed = {
. .\config.ps1
if ($Event.SourceArgs[1].Reason -eq 'UserCanceled') {
$msgid = $Event.SourceArgs.Tag
if(Test-Path -Path ".\cache\$msgid.jpg" -PathType Leaf){
Remove-Item -Path ".\cache\$msgid.jpg"
}
Invoke-RestMethod "https://$domain/message/$msgid" -SessionVariable session -Method Delete -Headers @{"X-Gotify-Key" = $token}
Stop-Process -Id $PID
}
}
$Activated = {
. .\config.ps1
$msgid = $Event.SourceArgs.Tag
if(Test-Path -Path ".\cache\$msgid.jpg" -PathType Leaf){
Remove-Item -Path ".\cache\$msgid.jpg"
}
Invoke-RestMethod "https://$domain/message/$msgid" -SessionVariable session -Method Delete -Headers @{"X-Gotify-Key" = $token}
Stop-Process -Id $PID
}
Submit-BTNotification -Content $Content -UniqueIdentifier $msgid -AppId "scooterpsu!Gotify" -DismissedAction $Dismissed -ActivatedAction $Activated
@cxtal
Copy link

cxtal commented Jun 18, 2025

You can also use Winify (I'm part of the developer team) and it has a very nice set of features! We created it such that it reaches back to Windows 7, just to be sure even old time users can enjoy it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment