# notify_decision.ps1 - ping the user when the agent needs a decision. # # Usage: # powershell -NoProfile -ExecutionPolicy Bypass -File tools\notify_decision.ps1 ` # -Title "Decision needed" -MessageFile C:\temp\q.txt [-Url ] [-NoToast] # # - Chinese text must go through -MessageFile (UTF-8) to avoid console codepage damage; # -Message is fine for ASCII. # - Every call appends to %USERPROFILE%\.pi\agent\decisions\pending.md so the question # is not lost if the toast is missed. # - Optional webhook: if -Url (or $env:PI_NOTIFY_WEBHOOK) is set, POST a text message. # WeCom / DingTalk style payload is the default; Feishu/Lark URLs get their own shape. # - Always exits 0: a failed notification must never break the agent turn. [CmdletBinding()] param( [string]$Title = 'Agent needs a decision', [string]$Message = '', [string]$MessageFile = '', [string]$Url = '', [switch]$NoToast, [switch]$NoBeep, [switch]$Probe ) $ErrorActionPreference = 'Continue' function Write-Pending { param([string]$text) try { $dir = Join-Path $env:USERPROFILE '.pi\agent\decisions' if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } $log = Join-Path $dir 'pending.md' $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $cwd = (Get-Location).Path $entry = "## $stamp`r`n`r`n- cwd: $cwd`r`n- title: $Title`r`n`r`n$text`r`n`r`n---`r`n" [System.IO.File]::AppendAllText($log, $entry, (New-Object System.Text.UTF8Encoding $false)) } catch { } } function Send-Toast { param([string]$t, [string]$m) try { [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] | Out-Null [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType=WindowsRuntime] | Out-Null $esc = { param($s) ($s -replace '&', '&' -replace '<', '<' -replace '>', '>') } $body = (& $esc $m) -replace "`r?`n", '' # split the message into lines -> one per line $lines = $m -split "`r?`n" | ForEach-Object { '' + (& $esc $_) + '' } $xmlText = @" $(& $esc $t) $($lines -join "`r`n ") "@ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument $xml.LoadXml($xmlText) $toast = New-Object Windows.UI.Notifications.ToastNotification $xml $appId = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe' [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast) return $true } catch { return $false } } function Send-Popup { # Toast can silently fail (session locked, notifications off, appId not registered). # A popup is intrusive but guaranteed visible; it auto-closes after 120 s. param([string]$t, [string]$m) try { $w = New-Object -ComObject Wscript.Shell $w.Popup("$t`r`n`r`n$m", 120, 'pi agent', 64) | Out-Null return $true } catch { return $false } } function Mask-Url { param([string]$u) if (-not $u) { return '(none)' } if ($u.Length -le 48) { return $u } return $u.Substring(0, 32) + '...' + $u.Substring($u.Length - 8) } function Mask-Token { param([string]$t) if (-not $t) { return '(none)' } if ($t.Length -le 12) { return '***' } return $t.Substring(0, 10) + '***' } function Get-NotifyConfig { # ~/.pi/agent/notify.json (outside the repo on purpose: tokens are secrets) # { # "webhook": "https://qyapi.weixin.qq.com/...", // WeCom / DingTalk / Feishu # "telegram": { "token": "123456:ABC...", "chatId": "123456789", # "proxy": "http://127.0.0.1:7897" } # } $cfg = @{ webhook = ''; tgToken = ''; tgChat = ''; proxy = '' } $path = Join-Path $env:USERPROFILE '.pi\agent\notify.json' if (Test-Path $path) { try { $o = [System.IO.File]::ReadAllText($path, [System.Text.Encoding]::UTF8) | ConvertFrom-Json if ($o.webhook) { $cfg.webhook = [string]$o.webhook } if ($o.proxy) { $cfg.proxy = [string]$o.proxy } if ($o.telegram) { if ($o.telegram.token) { $cfg.tgToken = [string]$o.telegram.token } if ($o.telegram.chatId) { $cfg.tgChat = [string]$o.telegram.chatId } if ($o.telegram.proxy) { $cfg.proxy = [string]$o.telegram.proxy } } } catch { } } $txtHook = Join-Path $env:USERPROFILE '.pi\agent\notify_webhook.txt' if (-not $cfg.webhook -and (Test-Path $txtHook)) { $v = ([System.IO.File]::ReadAllText($txtHook, [System.Text.Encoding]::UTF8)).Trim() if ($v) { $cfg.webhook = $v } } return $cfg } function Get-ProxyArg { param([string]$p) if (-not $p) { $p = $env:HTTPS_PROXY } if (-not $p) { $p = $env:https_proxy } return $p } function Send-Telegram { # Telegram Bot API. In mainland China this needs the local proxy; Invoke-RestMethod # otherwise falls back to the system (IE) proxy setting. param([string]$token, [string]$chatId, [string]$t, [string]$m, [string]$proxy) try { if (-not $token -or -not $chatId) { return 'not configured' } $txt = "$t`n$m" if ($txt.Length -gt 3900) { $txt = $txt.Substring(0, 3900) + ' ...' } $body = @{ chat_id = $chatId; text = $txt; disable_web_page_preview = $true } | ConvertTo-Json -Compress $bytes = [System.Text.Encoding]::UTF8.GetBytes($body) $uri = "https://api.telegram.org/bot$token/sendMessage" $args = @{ Uri = $uri; Method = 'Post' ContentType = 'application/json; charset=utf-8' Body = $bytes; TimeoutSec = 15 } $px = Get-ProxyArg $proxy if ($px) { $args['Proxy'] = $px } $resp = Invoke-RestMethod @args if ($resp.ok) { return 'ok' } return "not ok: $($resp.description)" } catch { return "http: $($_.Exception.Message)" } } function Post-Webhook { param([string]$u, [string]$t, [string]$m) try { $text = "$t`n$m" if ($u -match 'feishu|larksuite|lark') { $payload = @{ msg_type = 'text'; content = @{ text = $text } } | ConvertTo-Json -Depth 5 -Compress } else { # WeCom / DingTalk robot $payload = @{ msgtype = 'text'; text = @{ content = $text } } | ConvertTo-Json -Depth 5 -Compress } $bytes = [System.Text.Encoding]::UTF8.GetBytes($payload) $resp = Invoke-RestMethod -Uri $u -Method Post -ContentType 'application/json; charset=utf-8' -Body $bytes -TimeoutSec 10 # WeCom / DingTalk answer 200 with errcode; Feishu answers with code. 200 does NOT mean delivered. if ($null -ne $resp.errcode -and $resp.errcode -ne 0) { return "errcode=$($resp.errcode) $($resp.errmsg)" } if ($null -ne $resp.code -and $resp.code -ne 0) { return "code=$($resp.code) $($resp.msg)" } return 'ok' } catch { return "http: $($_.Exception.Message)" } } # ---- resolve message ------------------------------------------------------- $text = $Message if ($MessageFile -and (Test-Path $MessageFile)) { $text = [System.IO.File]::ReadAllText($MessageFile, [System.Text.Encoding]::UTF8) } if (-not $text) { $text = '(empty message)' } Write-Pending -text $text if (-not $NoToast) { $shown = Send-Toast -t $Title -m $text if (-not $shown) { Send-Popup -t $Title -m $text | Out-Null } } if (-not $NoBeep) { try { [console]::Beep(880, 180); [console]::Beep(1175, 220) } catch { } } $cfg = Get-NotifyConfig $hook = $Url if (-not $hook) { $hook = $env:PI_NOTIFY_WEBHOOK } if (-not $hook) { $hook = $cfg.webhook } $tgToken = $env:PI_NOTIFY_TELEGRAM_TOKEN if (-not $tgToken) { $tgToken = $cfg.tgToken } $tgChat = $env:PI_NOTIFY_TELEGRAM_CHAT if (-not $tgChat) { $tgChat = $cfg.tgChat } if ($hook) { $st = Post-Webhook -u $hook -t $Title -m $text if ($Probe) { Write-Output ("webhook -> " + (Mask-Url $hook) + " result: " + $st) } } elseif ($Probe) { Write-Output 'webhook not configured' } if ($tgToken -and $tgChat) { $st = Send-Telegram -token $tgToken -chatId $tgChat -t $Title -m $text -proxy $cfg.proxy if ($Probe) { Write-Output ("telegram -> token " + (Mask-Token $tgToken) + " chat " + $tgChat + " result: " + $st) } } elseif ($Probe) { Write-Output 'telegram not configured' } exit 0