Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
WECOM_WEBHOOK=
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
TELEGRAM_PROXY=
NOTIFY_SERVER_TOKEN=
NOTIFY_SERVER_PREFIX=
NOTIFY_SERVER_PORT=
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ Windows 侧在安装向导中勾选“远程通知服务端”即可。
## Troubleshooting
- Windows 通知无效:确认 BurntToast 安装、系统通知未关闭
- Telegram 无响应:确认 Bot Token / Chat ID 正确
- Telegram 连接超时/被墙:在 `.env` 里设置 `TELEGRAM_PROXY=http://127.0.0.1:7890`(HTTP/HTTPS 代理地址)
- 远程图片失败:请安装 OpenSSH Client 或 PuTTY(pscp)并加入 PATH

## 维护者:构建与发布
Expand Down
12 changes: 10 additions & 2 deletions bin/notify-setup.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ function Ensure-EnvTemplate {
"WECOM_WEBHOOK=",
"TELEGRAM_BOT_TOKEN=",
"TELEGRAM_CHAT_ID=",
"TELEGRAM_PROXY=",
"NOTIFY_SERVER_TOKEN=",
"NOTIFY_SERVER_PREFIX=",
"NOTIFY_SERVER_PORT=",
Expand Down Expand Up @@ -205,12 +206,19 @@ function Write-EmbeddedFiles {

function Install-Payload {
param([string]$targetDir)
$sourceDir = $PSScriptRoot
$hasLocal = Test-Path (Join-Path $sourceDir "notify.ps1")
if ($EmbeddedFiles.Count -gt 0) {
Write-EmbeddedFiles -targetDir $targetDir
return
}
$sourceDir = $PSScriptRoot
if ([string]::IsNullOrWhiteSpace($sourceDir)) {
$self = Get-SelfPath
if ($self) { $sourceDir = Split-Path -Parent $self }
}
$hasLocal = $false
if (-not [string]::IsNullOrWhiteSpace($sourceDir)) {
$hasLocal = Test-Path (Join-Path $sourceDir "notify.ps1")
}
if ($hasLocal) {
Get-ChildItem -Path $sourceDir -File | Where-Object { $_.Name -ne "notify-setup.ps1" } | ForEach-Object {
Copy-Item -Path $_.FullName -Destination (Join-Path $targetDir $_.Name) -Force
Expand Down
49 changes: 44 additions & 5 deletions bin/notify.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,19 @@ function Get-NotifySetting {
try { return [Environment]::GetEnvironmentVariable($name, "User") } catch { return $null }
}

function Normalize-ProxyUri {
param([string]$s)
if (-not $s) { return $null }
$t = $s.Trim()
if (-not $t) { return $null }
if ($t -notmatch '^[a-zA-Z][a-zA-Z0-9+.-]*://') { $t = "http://$t" }
try {
$u = [uri]$t
if ($u.Scheme -ne "http" -and $u.Scheme -ne "https") { return $null }
return $u
} catch { return $null }
}

$notifyConfig = Load-NotifyConfig

# Global mute: env var or disable file
Expand All @@ -85,11 +98,29 @@ $flagTg = Join-Path $PSScriptRoot "notify.telegram.disabled"
$flagDebug = Join-Path $PSScriptRoot "notify.debug.enabled"
$debugEnabled = Test-Path $flagDebug

# Log setup (keep only 1 day) if debug enabled
$logDir = Join-Path $env:LOCALAPPDATA "notify"
# State/log directory (session/thread maps + optional debug logs)
$logDir = $null
try {
$baseDir = $env:LOCALAPPDATA
if ([string]::IsNullOrWhiteSpace($baseDir)) {
try { $baseDir = [Environment]::GetFolderPath('LocalApplicationData') } catch { $baseDir = $null }
}
if ([string]::IsNullOrWhiteSpace($baseDir)) { $baseDir = $env:TEMP }
if ([string]::IsNullOrWhiteSpace($baseDir)) { $baseDir = (Get-Location).Path }

foreach ($name in @("notify", "notify.d", "notify-dir")) {
$candidate = Join-Path $baseDir $name
if (Test-Path -Path $candidate -PathType Leaf) { continue }
if (-not (Test-Path -Path $candidate -PathType Container)) {
New-Item -ItemType Directory -Path $candidate -Force -ErrorAction Stop | Out-Null
}
$logDir = $candidate
break
}
} catch {}
if (-not $logDir) { $logDir = "." }
$logFile = Join-Path $logDir ("notify-" + (Get-Date -Format 'yyyyMMdd') + ".log")
if ($debugEnabled) {
try { if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } } catch {}
try {
Get-ChildItem -Path $logDir -Filter "notify-*.log" -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).Date.AddDays(-1) } |
Expand All @@ -112,6 +143,7 @@ if (-not $WebhookUrl) { $WebhookUrl = Get-NotifySetting -name "WECOM_WEBHOOK" -c
# Telegram config from config/env
$tgToken = Get-NotifySetting -name "TELEGRAM_BOT_TOKEN" -cfg $notifyConfig
$tgChat = Get-NotifySetting -name "TELEGRAM_CHAT_ID" -cfg $notifyConfig
$tgProxyUri = Normalize-ProxyUri (Get-NotifySetting -name "TELEGRAM_PROXY" -cfg $notifyConfig)

# Read stdin when invoked as a hook (e.g., Claude Code sends JSON)
$raw = ""
Expand Down Expand Up @@ -503,8 +535,15 @@ if (Test-Path $flagTg) {
if ($tgThreadId) {
try { $tgPayload.message_thread_id = [int]$tgThreadId } catch {}
}
$tgPayload = $tgPayload | ConvertTo-Json
$resp = Invoke-RestMethod -Method Post -Uri $tgUri -ContentType 'application/json; charset=utf-8' -Body $tgPayload
$tgPayloadJson = $tgPayload | ConvertTo-Json
$irm = @{
Method = "Post"
Uri = $tgUri
ContentType = 'application/json; charset=utf-8'
Body = $tgPayloadJson
}
if ($tgProxyUri) { $irm.Proxy = $tgProxyUri }
$resp = Invoke-RestMethod @irm
try {
if ($resp -and $resp.result -and $resp.result.message_id) { return [string]$resp.result.message_id }
} catch {}
Expand Down
37 changes: 33 additions & 4 deletions bin/telegram-bridge.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -82,9 +82,23 @@ function Get-NotifySetting {
return (Get-EnvUser $name)
}

function Normalize-ProxyUri {
param([string]$s)
if (-not $s) { return $null }
$t = $s.Trim()
if (-not $t) { return $null }
if ($t -notmatch '^[a-zA-Z][a-zA-Z0-9+.-]*://') { $t = "http://$t" }
try {
$u = [uri]$t
if ($u.Scheme -ne "http" -and $u.Scheme -ne "https") { return $null }
return $u
} catch { return $null }
}

$notifyConfig = Load-NotifyConfig
$token = Get-NotifySetting -name "TELEGRAM_BOT_TOKEN" -cfg $notifyConfig
$chatId = Get-NotifySetting -name "TELEGRAM_CHAT_ID" -cfg $notifyConfig
$tgProxyUri = Normalize-ProxyUri (Get-NotifySetting -name "TELEGRAM_PROXY" -cfg $notifyConfig)

if (-not $token -or -not $chatId) {
Write-BridgeLog "missing token/chat_id; exit"
Expand All @@ -100,7 +114,14 @@ function Send-Tg {
try { $payload.message_thread_id = [int]$threadId } catch {}
}
$body = $payload | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri $uri -ContentType 'application/json; charset=utf-8' -Body $body | Out-Null
$irm = @{
Method = "Post"
Uri = $uri
ContentType = 'application/json; charset=utf-8'
Body = $body
}
if ($tgProxyUri) { $irm.Proxy = $tgProxyUri }
Invoke-RestMethod @irm | Out-Null
} catch {
Write-BridgeLog ("send fail: " + $_.Exception.Message)
}
Expand Down Expand Up @@ -255,7 +276,9 @@ function Get-TgFilePath {
if (-not $fileId) { return $null }
try {
$uri = "https://api.telegram.org/bot$token/getFile?file_id=$fileId"
$resp = Invoke-RestMethod -Method Get -Uri $uri
$irm = @{ Method = "Get"; Uri = $uri }
if ($tgProxyUri) { $irm.Proxy = $tgProxyUri }
$resp = Invoke-RestMethod @irm
if ($resp -and $resp.result -and $resp.result.file_path) { return $resp.result.file_path }
} catch {}
return $null
Expand Down Expand Up @@ -284,7 +307,11 @@ function Save-MessageImage {
$name = "tg_" + $msg.message_id + $ext
$dest = Join-Path $dir $name
$downloadUrl = "https://api.telegram.org/file/bot$token/$filePath"
try { Invoke-WebRequest -Uri $downloadUrl -OutFile $dest | Out-Null } catch { return $null }
try {
$iwr = @{ Uri = $downloadUrl; OutFile = $dest }
if ($tgProxyUri) { $iwr.Proxy = $tgProxyUri }
Invoke-WebRequest @iwr | Out-Null
} catch { return $null }

$ref = $dest
if ($useRelative) { $ref = (".notify\\" + $name) }
Expand Down Expand Up @@ -629,7 +656,9 @@ Send-Tg "Telegram 控制桥已启动。发送 /help 查看命令。"
while ($true) {
try {
$uri = "https://api.telegram.org/bot$token/getUpdates?timeout=30&offset=$offset"
$resp = Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec 35
$irm = @{ Method = "Get"; Uri = $uri; TimeoutSec = 35 }
if ($tgProxyUri) { $irm.Proxy = $tgProxyUri }
$resp = Invoke-RestMethod @irm
} catch {
Write-BridgeLog ("getUpdates fail: " + $_.Exception.Message)
Start-Sleep -Seconds $PollSeconds
Expand Down
159 changes: 159 additions & 0 deletions tools/self-test.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#requires -Version 5.1
[CmdletBinding()]
param()

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

function Assert-True {
param(
[Parameter(Mandatory = $true)][bool]$Condition,
[Parameter(Mandatory = $true)][string]$Message
)
if (-not $Condition) { throw "ASSERT: $Message" }
}

function New-TempDir {
param([string]$Prefix = "cli_notify_test")
$base = [IO.Path]::GetTempPath()
$dir = Join-Path $base ($Prefix + "_" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $dir -Force | Out-Null
return $dir
}

function Invoke-Pwsh {
param([Parameter(Mandatory = $true)][string]$Command)
& pwsh -NoProfile -ExecutionPolicy Bypass -Command $Command
return $LASTEXITCODE
}

$repoRoot = Split-Path -Parent $PSScriptRoot
$binDir = Join-Path $repoRoot "bin"
$notifyPath = Join-Path $binDir "notify.ps1"
$notifySetupPath = Join-Path $binDir "notify-setup.ps1"

Assert-True -Condition (Test-Path $notifyPath) -Message "Missing: $notifyPath"
Assert-True -Condition (Test-Path $notifySetupPath) -Message "Missing: $notifySetupPath"

# Disable side effects (toast/network) during tests
$disableFlags = @(
(Join-Path $binDir "notify.windows.disabled"),
(Join-Path $binDir "notify.wecom.disabled"),
(Join-Path $binDir "notify.telegram.disabled")
)
$createdFlags = @()
foreach ($f in $disableFlags) {
if (-not (Test-Path $f)) {
New-Item -ItemType File -Path $f -Force | Out-Null
$createdFlags += $f
}
}

try {
Write-Host "TEST: notify.ps1 handles empty LOCALAPPDATA"
$notifyPathEsc = $notifyPath.Replace("'", "''")
$cmd = @'
$ErrorActionPreference = 'Stop'
$env:LOCALAPPDATA = ''
$env:TELEGRAM_BOT_TOKEN = ''
$env:TELEGRAM_CHAT_ID = ''
try {
& '__NOTIFY_PATH__' -Source 'Test'
exit 0
} catch {
Write-Error $_.Exception.Message
exit 1
}
'@
$cmd = $cmd.Replace("__NOTIFY_PATH__", $notifyPathEsc)
$exit = Invoke-Pwsh -Command $cmd
Assert-True -Condition ($exit -eq 0) -Message "notify.ps1 crashed with empty LOCALAPPDATA (exit $exit)"

Write-Host "TEST: notify.ps1 passes TELEGRAM_PROXY to Invoke-RestMethod"
$tgDisabled = Join-Path $binDir "notify.telegram.disabled"
$hadTgDisabled = Test-Path $tgDisabled
if ($hadTgDisabled) { Remove-Item -Path $tgDisabled -Force -ErrorAction SilentlyContinue }
try {
$cmdProxy = @'
$ErrorActionPreference = 'Stop'

$global:irmCalled = $false
$global:proxySeen = $false
function global:Invoke-RestMethod {
[CmdletBinding()]
param(
[string]$Method,
[string]$Uri,
[string]$ContentType,
$Body,
$Proxy
)
$global:irmCalled = $true
if ($PSBoundParameters.ContainsKey('Proxy') -and $Proxy) { $global:proxySeen = $true }
return [pscustomobject]@{ result = [pscustomobject]@{ message_id = 1 } }
}

$env:TELEGRAM_BOT_TOKEN = 'dummy'
$env:TELEGRAM_CHAT_ID = '123'
$env:TELEGRAM_PROXY = 'http://127.0.0.1:7890'

& '__NOTIFY_PATH__' -Source 'Test' -Title 'T' -Body 'B'

if (-not $global:irmCalled) { Write-Error 'Invoke-RestMethod not called'; exit 1 }
if (-not $global:proxySeen) { Write-Error 'missing -Proxy'; exit 1 }
exit 0
'@
$cmdProxy = $cmdProxy.Replace("__NOTIFY_PATH__", $notifyPathEsc)
$exitProxy = Invoke-Pwsh -Command $cmdProxy
Assert-True -Condition ($exitProxy -eq 0) -Message "notify.ps1 did not pass TELEGRAM_PROXY (exit $exitProxy)"
} finally {
if ($hadTgDisabled) { New-Item -ItemType File -Path $tgDisabled -Force | Out-Null }
}

Write-Host "TEST: notify-setup.ps1 Install-Payload works when PSScriptRoot empty"
$srcDir = New-TempDir -Prefix "cli_notify_payload_src"
$dstDir = New-TempDir -Prefix "cli_notify_payload_dst"
Set-Content -Path (Join-Path $srcDir "notify.ps1") -Value "# dummy" -Encoding UTF8
Set-Content -Path (Join-Path $srcDir "remote-batch.ps1") -Value "# dummy" -Encoding UTF8
Set-Content -Path (Join-Path $srcDir "notify-setup.ps1") -Value "# dummy" -Encoding UTF8

$notifySetupPathEsc = $notifySetupPath.Replace("'", "''")
$srcDirEsc = $srcDir.Replace("'", "''")
$dstDirEsc = $dstDir.Replace("'", "''")
$cmd2 = @'
$ErrorActionPreference = 'Stop'
$notifySetup = '__SETUP_PATH__'
$srcDir = '__SRC_DIR__'
$dstDir = '__DST_DIR__'

$content = Get-Content -Path $notifySetup -Raw
$tokens = $null
$errors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$errors)
if ($errors -and $errors.Count -gt 0) {
throw ('Parse errors: ' + ($errors | ForEach-Object { $_.Message } | Out-String))
}

$f = $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq 'Install-Payload' }, $true) | Select-Object -First 1
if (-not $f) { throw "Missing function: Install-Payload" }
Invoke-Expression $f.Extent.Text

$EmbeddedFiles = @{}
function Get-SelfPath { return (Join-Path $srcDir 'notify-setup.ps1') }

Install-Payload -targetDir $dstDir

$copied = Join-Path $dstDir 'notify.ps1'
if (-not (Test-Path $copied)) { throw 'notify.ps1 not copied' }
exit 0
'@
$cmd2 = $cmd2.Replace("__SETUP_PATH__", $notifySetupPathEsc).Replace("__SRC_DIR__", $srcDirEsc).Replace("__DST_DIR__", $dstDirEsc)
$exit2 = Invoke-Pwsh -Command $cmd2
Assert-True -Condition ($exit2 -eq 0) -Message "Install-Payload failed when PSScriptRoot empty (exit $exit2)"

Write-Host "ALL TESTS PASSED"
} finally {
foreach ($f in $createdFlags) {
Remove-Item -Path $f -Force -ErrorAction SilentlyContinue
}
}