Files
envi-code/tools/notify_decision.ps1
T
company-desktop 18aa1d1260 feat: 通知脚本支持 Telegram 通道
用户没有企业微信,改用 Telegram Bot。同一台机器的 api.telegram.org 需要走本地代理
(实测 127.0.0.1:7897),所以发送逻辑要显式支持代理。

- notify.json 新增 telegram 段:token / chatId / proxy
  (也认环境变量 PI_NOTIFY_TELEGRAM_TOKEN、PI_NOTIFY_TELEGRAM_CHAT)
- 未配置 proxy 时回落 HTTPS_PROXY 环境变量,再回落系统(IE)代理
- 文本超过 3900 字符截断(Telegram 上限 4096)
- 自检 -Probe 同时报告 webhook 与 telegram 两个通道的结果,token 只显示前 10 位
- SKILL.md 更新通道配置说明,补上 notify.json 的完整示例与代理注意事项

实测:假 token 时返回 "(401) 未经授权",说明请求确实穿过了代理到达 api.telegram.org,
只是凭据无效;未配置时正确报告 not configured。真实推送待用户提供 bot token。
2026-10-28 15:55:13 +08:00

226 lines
8.5 KiB
PowerShell

# 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 <webhook>] [-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 '&', '&amp;' -replace '<', '&lt;' -replace '>', '&gt;') }
$body = (& $esc $m) -replace "`r?`n", '<text>'
# split the message into lines -> one <text> per line
$lines = $m -split "`r?`n" | ForEach-Object { '<text>' + (& $esc $_) + '</text>' }
$xmlText = @"
<toast scenario="reminder">
<visual>
<binding template="ToastGeneric">
<text>$(& $esc $t)</text>
$($lines -join "`r`n ")
</binding>
</visual>
<audio src="ms-winsoundevent:Notification.Reminder"/>
</toast>
"@
$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