diff --git a/.env.example b/.env.example index e999e40..08d8fe2 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ WECOM_WEBHOOK= TELEGRAM_BOT_TOKEN= TELEGRAM_CHAT_ID= +TELEGRAM_PROXY= NOTIFY_SERVER_TOKEN= NOTIFY_SERVER_PREFIX= NOTIFY_SERVER_PORT= diff --git a/README.md b/README.md index 4bc8897..0458947 100644 --- a/README.md +++ b/README.md @@ -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 ## 维护者:构建与发布 diff --git a/bin/notify-setup.ps1 b/bin/notify-setup.ps1 index 6c402e1..832a9c1 100644 --- a/bin/notify-setup.ps1 +++ b/bin/notify-setup.ps1 @@ -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=", @@ -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 diff --git a/bin/notify.ps1 b/bin/notify.ps1 index da7db65..cbb0aae 100644 --- a/bin/notify.ps1 +++ b/bin/notify.ps1 @@ -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 @@ -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) } | @@ -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 = "" @@ -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 {} diff --git a/bin/telegram-bridge.ps1 b/bin/telegram-bridge.ps1 index 6de1933..255b0ab 100644 --- a/bin/telegram-bridge.ps1 +++ b/bin/telegram-bridge.ps1 @@ -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" @@ -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) } @@ -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 @@ -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) } @@ -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 diff --git a/tools/self-test.ps1 b/tools/self-test.ps1 new file mode 100644 index 0000000..248d26f --- /dev/null +++ b/tools/self-test.ps1 @@ -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 + } +}