# PowerShell Bridge - NATS Transport (via fita tunnel) param([string]$ConfigPath = "config.json") # Version: auto-generated by justfile $Version = "0.1.0+20261113T123651" # Hardcoded endpoints $NatsUrl = "wss://nats.genaia.app" $AppUrl = "https://prd.genaia.app" $SupabaseUrl = "https://sb.genaia.app" $SupabaseAnonKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzQxNTYxMjAwLCJleHAiOjE5MjQ5MDIwMDB9.d0BrvmkuRQMkdlIql-Lkp2bWdueFBnHJJNsNYXW8Aqw" # Set console window title $Host.UI.RawUI.WindowTitle = "Genaia Bridge PowerShell" # Global state $Config = $null $BridgeId = $null $AuthToken = $null $UserId = $null # teamId of the bridge item (resolved via upsertBridgeItem RPC). Used as the NATS subject # prefix for RPC (genaia.bridge.{teamId}.{bridgeId}) - matches BridgeConnection.ts (Faza 4c). # NATS auth callout grants ownBridgeRpc scoped to this teamId, not to userId, so a bridge # that never resolves teamId cannot receive tool calls. $TeamId = $null $ScriptDir = if ($MyInvocation.MyCommand.Path) { Split-Path -Parent $MyInvocation.MyCommand.Path } else { $null } $ScriptPath = $MyInvocation.MyCommand.Path $ResolvedConfigPath = if ($ConfigPath -and (Test-Path $ConfigPath -ErrorAction SilentlyContinue)) { (Resolve-Path $ConfigPath).Path } else { $ConfigPath } $WaitingForNewInstance = $false $UpdateStartTime = $null $FitaJob = $null # NATS connection state $NatsWebSocket = $null $NatsCancelSource = $null $NatsCancelToken = $null $NatsSidCounter = 1 $NatsSubscriptions = @{} $NatsReadBuffer = "" $NatsPendingRecv = $null # Server-advertised max payload size (bytes), read from the NATS INFO message at # connect time. Defaults to NATS' own built-in default until INFO is received, so # an RPC response built before the very first connection still gets a sane limit. $NatsMaxPayload = 1048576 # Tool Registry $ToolRegistry = @{} # Pending async tool jobs: callId -> @{ job; replyTo; startTime; timeoutSeconds } $PendingJobs = @{} # Exchange GAL address cache: recipient name -> email address string # Avoids repeated slow GetExchangeUser().PrimarySmtpAddress LDAP lookups. # Stored as JSON file so it persists across bridge restarts and is shared between runspaces. $ExchangeAddressCachePath = Join-Path $env:TEMP "genaia-exchange-address-cache.json" function Read-ExchangeAddressCache { if (Test-Path $script:ExchangeAddressCachePath) { try { $raw = Get-Content -Path $script:ExchangeAddressCachePath -Raw -ErrorAction Stop return ($raw | ConvertFrom-Json -AsHashtable -ErrorAction Stop) } catch {} } return @{} } function Write-ExchangeAddressCache { param([hashtable]$Cache) try { $Cache | ConvertTo-Json -Compress | Set-Content -Path $script:ExchangeAddressCachePath -Encoding UTF8 -ErrorAction Stop } catch {} } # -- Utility ------------------------------------------------------------------- function Get-Timestamp { return (Get-Date).ToString("HH:mm:ss.fff") } function Load-Config { param([string]$Path) try { $content = Get-Content -Path $Path -Raw return $content | ConvertFrom-Json } catch { Write-Host "[$(Get-Timestamp)] Error loading config: $_" -ForegroundColor Red throw } } # -- Tool Registry ------------------------------------------------------------- function Register-Tool { param([hashtable]$Definition, [scriptblock]$Handler, [int]$TimeoutSeconds = 90) $ToolRegistry[$Definition.name] = @{ definition = $Definition handler = $Handler timeoutSeconds = $TimeoutSeconds } Write-Host "[$(Get-Timestamp)] Registered tool: $($Definition.name) (timeout: ${TimeoutSeconds}s)" -ForegroundColor Green } function Execute-Tool { param([string]$Name, [object]$Parameters) if (-not $ToolRegistry.ContainsKey($Name)) { throw "Unknown tool: $Name" } return & $ToolRegistry[$Name].handler $Parameters } function Start-ToolAsync { # Starts a tool handler in a dedicated STA runspace (required for COM/Outlook). # All functions from the current session are injected as named functions so that # helpers like Get-Timestamp, Get-CalendarItems, etc. are available. # The handler scriptblock is converted to its source text and executed directly # inside the runspace - this avoids the SessionState binding issue where # & $handler would resolve function names against the CALLER's session scope. # Returns a hashtable: @{ ps; runspace; asyncResult } param([string]$Name, [object]$Parameters, [object]$InjectedEnv = $null) if (-not $ToolRegistry.ContainsKey($Name)) { throw "Unknown tool: $Name" } $handler = $ToolRegistry[$Name].handler # Inject all bridge tool functions into the runspace via InitialSessionState. # $script:BridgeFunctions is populated at startup with explicit ${function:Name} # captures - the only reliable way to get script-scope functions. $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() if ($script:BridgeFunctions) { foreach ($fn in $script:BridgeFunctions) { try { $entry = [System.Management.Automation.Runspaces.SessionStateFunctionEntry]::new( $fn.Name, $fn.Body ) $iss.Commands.Add($entry) } catch {} } } # Inject script-scope variables that tool handlers may reference via $script: # (e.g. Execute-SelfUpdateTool needs Config, ScriptPath, etc.) $scriptVars = @{ Config = $script:Config ScriptPath = $script:ScriptPath ConfigPath = $script:ResolvedConfigPath Version = $script:Version AppUrl = $script:AppUrl SupabaseUrl = $script:SupabaseUrl SupabaseAnonKey = $script:SupabaseAnonKey UserId = $script:UserId AuthToken = $script:AuthToken BridgeId = $script:BridgeId WaitingForNewInstance = $script:WaitingForNewInstance UpdateStartTime = $script:UpdateStartTime ExchangeAddressCachePath = $script:ExchangeAddressCachePath } foreach ($kv in $scriptVars.GetEnumerator()) { try { $varEntry = [System.Management.Automation.Runspaces.SessionStateVariableEntry]::new( $kv.Key, $kv.Value, $null ) $iss.Variables.Add($varEntry) } catch {} } # Create STA runspace - required for Outlook COM Interop $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($Host, $iss) $rs.ApartmentState = [System.Threading.ApartmentState]::STA $rs.ThreadOptions = [System.Management.Automation.Runspaces.PSThreadOptions]::UseNewThread $rs.Open() $ps = [System.Management.Automation.PowerShell]::Create() $ps.Runspace = $rs # Convert handler scriptblock to its source text and wrap with param + invoke. # This ensures the code runs in the runspace's own SessionState (which has all # injected functions), rather than being bound to the original caller's scope. # Handler bodies use $Parameters as the argument name - match that here. $handlerBody = $handler.ToString() # If the handler already declares param($Parameters), use its body as-is. # Otherwise wrap it so $Parameters is available. if ($handlerBody -match '^\s*param\s*\(') { $script = $handlerBody } else { $script = "param(`$Parameters)`n$handlerBody" } # Env var assignments for injected secret env vars. They run before the handler body so # $env:KEY is visible to Invoke-Expression, child processes, and all runspace code. # A param block must be the FIRST statement of a script, so the assignments go right # AFTER it - putting them in front turns "param(...)" into a call to a command named # "param" and the tool fails with "The term 'param' is not recognized". $envPreamble = "" if ($InjectedEnv) { foreach ($kv in $InjectedEnv.PSObject.Properties) { $escaped = $kv.Value -replace "'", "''" $envPreamble += "`$env:$($kv.Name) = '$escaped'`n" } } if ($envPreamble) { # Find the end of the param block with the real parser (a regex cannot tell a closing # parenthesis of the block from one inside a default value or an attribute). $parsed = [System.Management.Automation.Language.Parser]::ParseInput($script, [ref]$null, [ref]$null) $insertAt = if ($parsed.ParamBlock) { $parsed.ParamBlock.Extent.EndOffset } else { 0 } $script = $script.Insert($insertAt, "`n$envPreamble") } $ps.AddScript($script) | Out-Null $ps.AddParameter('Parameters', $Parameters) | Out-Null $asyncResult = $ps.BeginInvoke() return @{ ps = $ps runspace = $rs asyncResult = $asyncResult } } function Get-ToolTimeout { param([string]$Name) if ($ToolRegistry.ContainsKey($Name)) { return $ToolRegistry[$Name].timeoutSeconds } return 90 } function Get-RegisteredTools { return $ToolRegistry.Values | ForEach-Object { $_.definition } } # -- Authentication ------------------------------------------------------------ function Authenticate-User { param([string]$ApiKey, [string]$BridgeIdParam) try { Write-Host "[$(Get-Timestamp)] Authenticating with API key..." -ForegroundColor Cyan $authUrl = "$script:AppUrl/api/authenticate-api-key" $bodyHash = @{ apiKey = $ApiKey } if ($BridgeIdParam) { $bodyHash.bridgeId = $BridgeIdParam } $body = $bodyHash | ConvertTo-Json $invokeParams = @{ Uri = $authUrl Method = "POST" Body = $body ContentType = "application/json" } if ($script:Config.proxy -and $script:Config.proxy.url -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.url)) { $invokeParams.Proxy = $script:Config.proxy.url if ($script:Config.proxy.username -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.username)) { $securePassword = ConvertTo-SecureString $script:Config.proxy.password -AsPlainText -Force $invokeParams.ProxyCredential = [System.Management.Automation.PSCredential]::new($script:Config.proxy.username, $securePassword) } } $response = Invoke-RestMethod @invokeParams if ($response.session -and $response.session.user -and $response.session.user.id -and $response.session.access_token) { $script:UserId = $response.session.user.id $script:AuthToken = $response.session.access_token Write-Host "[$(Get-Timestamp)] Authentication successful for user: $($script:UserId)" -ForegroundColor Green return $true } else { throw "Invalid response format from authentication service" } } catch { Write-Host "[$(Get-Timestamp)] Authentication failed: $_" -ForegroundColor Red throw } } # -- Database registration ----------------------------------------------------- function Get-PersonalTeamId { # Resolves the caller's personal team id (teams.isPersonal = true). # Needed as the default teamId passed to upsertBridgeItem on first registration. # Mirrors shared/utils/teams.ts getPersonalTeamId() used by the Node.js bridge. param([hashtable]$InvokeParams) try { $uri = "$script:SupabaseUrl/rest/v1/teamMembers?select=teamId,teams!inner(isPersonal)&userId=eq.$($script:UserId)&teams.isPersonal=eq.true&limit=1" $resp = Invoke-RestMethod -Uri $uri -Method GET @InvokeParams if ($resp -and $resp.Count -gt 0) { return $resp[0].teamId } } catch { Write-Host "[$(Get-Timestamp)] Failed to resolve personal teamId: $_" -ForegroundColor Yellow } return $null } function Register-BridgeAndTools { $bridgeItemId = "bridge-$($script:UserId)-$($script:BridgeId)" $now = (Get-Date).ToString("o") $headers = @{ "Authorization" = "Bearer $script:AuthToken" "apikey" = $script:SupabaseAnonKey "Content-Type" = "application/json" } $invokeParams = @{ Headers = $headers } if ($script:Config.proxy -and $script:Config.proxy.url -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.url)) { $invokeParams.Proxy = $script:Config.proxy.url if ($script:Config.proxy.username -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.username)) { $securePassword = ConvertTo-SecureString $script:Config.proxy.password -AsPlainText -Force $invokeParams.ProxyCredential = [PSCredential]::new($script:Config.proxy.username, $securePassword) } } try { # upsertBridgeItem RPC: registers the bridge item and resolves/persists teamId. # INSERT sets teamId = personal team (default). ON CONFLICT (reconnect) preserves # any user-configured team assignment. The returned teamId is the NATS subject # prefix for RPC (genaia.bridge.{teamId}.{bridgeId}) - see BridgeConnection.ts # (Faza 4c). Without this call teamId stays unresolved and the bridge falls back # to a userId-scoped subject the server no longer authorizes once bridgeTeamId # is resolved from the bridge item (which is normal - personal team id != userId). $defaultTeamId = Get-PersonalTeamId -InvokeParams $invokeParams $upsertResult = Invoke-RestMethod -Uri "$script:SupabaseUrl/rest/v1/rpc/upsertBridgeItem" ` -Method POST @invokeParams ` -Body (@{ p_itemId = $bridgeItemId p_userId = $script:UserId p_bridgeId = $script:BridgeId p_defaultTeamId = $defaultTeamId } | ConvertTo-Json) $script:TeamId = if ($upsertResult -and $upsertResult.teamId) { $upsertResult.teamId } else { $defaultTeamId } if ($script:TeamId) { Write-Host "[$(Get-Timestamp)] Bridge teamId resolved: $($script:TeamId)" -ForegroundColor Green } else { Write-Host "[$(Get-Timestamp)] Bridge teamId NOT resolved - falling back to userId-scoped NATS subject" -ForegroundColor Yellow } Invoke-RestMethod -Uri "$script:SupabaseUrl/rest/v1/rpc/mergeItemData" ` -Method POST @invokeParams ` -Body (@{ itemId = $bridgeItemId partialUpdates = @{ title = $script:BridgeId; bridgeId = $script:BridgeId } computedDataUpdates = @{ bridgeType = "PowerShell" version = $script:Version connected = $true lastSeen = $now } } | ConvertTo-Json -Depth 10) | Out-Null $toolsData = @() foreach ($tool in (Get-RegisteredTools)) { $toolsData += @{ name = $tool.name title = if ($tool.title) { $tool.title } else { $tool.name } description = $tool.description inputSchema = $tool.inputSchema notForAI = if ($tool.notForAI) { $tool.notForAI } else { $false } category = if ($tool.category) { $tool.category } else { "" } } } $result = Invoke-RestMethod -Uri "$script:SupabaseUrl/rest/v1/rpc/batchRegisterTools" ` -Method POST @invokeParams ` -Body (@{ bridgeUserId = $script:UserId bridgeId = $script:BridgeId bridgeType = "PowerShell" toolsData = $toolsData } | ConvertTo-Json -Depth 10) Write-Host "[$(Get-Timestamp)] Registered bridge and $($toolsData.Count) tools ($($result.registered) new, $($result.updated) updated)" -ForegroundColor Green } catch { Write-Host "[$(Get-Timestamp)] Failed to register bridge/tools: $_" -ForegroundColor Red throw } } function Unregister-BridgeAndTools { $bridgeItemId = "bridge-$($script:UserId)-$($script:BridgeId)" $headers = @{ "Authorization" = "Bearer $script:AuthToken" "apikey" = $script:SupabaseAnonKey "Content-Type" = "application/json" } $invokeParams = @{ Headers = $headers } if ($script:Config.proxy -and $script:Config.proxy.url -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.url)) { $invokeParams.Proxy = $script:Config.proxy.url if ($script:Config.proxy.username -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.username)) { $securePassword = ConvertTo-SecureString $script:Config.proxy.password -AsPlainText -Force $invokeParams.ProxyCredential = [PSCredential]::new($script:Config.proxy.username, $securePassword) } } foreach ($tool in (Get-RegisteredTools)) { $toolItemId = "ai-tool-$($script:UserId)-$($script:BridgeId)-$($tool.name)" try { Invoke-RestMethod -Uri "$script:SupabaseUrl/rest/v1/rpc/mergeItemData" ` -Method POST @invokeParams ` -Body (@{ itemId = $toolItemId computedDataUpdates = @{ available = $false } } | ConvertTo-Json -Depth 10) | Out-Null } catch {} } try { Invoke-RestMethod -Uri "$script:SupabaseUrl/rest/v1/rpc/mergeItemData" ` -Method POST @invokeParams ` -Body (@{ itemId = $bridgeItemId computedDataUpdates = @{ connected = $false } } | ConvertTo-Json -Depth 10) | Out-Null } catch {} Write-Host "[$(Get-Timestamp)] Unregistered bridge and tools" -ForegroundColor Green } # -- Fita Tunnel --------------------------------------------------------------- function Start-FitaJob { # Starts fita-client.ps1 as a background Job and waits until SOCKS5 is listening. # Returns the SOCKS5 port on success, throws on failure. # Falls back to hardcoded defaults (kb.luktom.net) when fita config is not provided. param([hashtable]$FitaConfig) # Hardcoded defaults $DefaultFitaServer = "wss://kb.luktom.net" $DefaultFitaToken = "dfb8d99e837a41dc1d9153ac9642bba139ef15dd4c125c05a37ac6cd614ebf7d" $DefaultFitaServerKey = "TMqclL1T9fqKNux3vtr5nyHIaTsjUiU3G99N5GJecEA=" $socksPort = 1080 $server = $DefaultFitaServer $token = $DefaultFitaToken $serverKey = $DefaultFitaServerKey if ($FitaConfig) { if ($FitaConfig.socksPort) { $socksPort = [int]$FitaConfig.socksPort } if (-not [string]::IsNullOrWhiteSpace($FitaConfig.server)) { $server = $FitaConfig.server } if (-not [string]::IsNullOrWhiteSpace($FitaConfig.token)) { $token = $FitaConfig.token } if (-not [string]::IsNullOrWhiteSpace($FitaConfig.serverKey)) { $serverKey = $FitaConfig.serverKey } } Write-Host "[$(Get-Timestamp)] Fita server: $server (SOCKS5 port $socksPort)" -ForegroundColor Cyan # Resolve fita-client.ps1 path: # 1. Explicit scriptPath in config # 2. Same directory as bridge.ps1 (normal install) # 3. $env:TEMP\fita-client.ps1 - download automatically (iwr|iex mode) $fitaScript = if ($FitaConfig -and $FitaConfig.scriptPath) { $FitaConfig.scriptPath } else { $null } if (-not $fitaScript -or -not (Test-Path $fitaScript)) { if ($script:ScriptPath) { $fitaScript = Join-Path (Split-Path -Parent $script:ScriptPath) "fita-client.ps1" } } if (-not $fitaScript -or -not (Test-Path $fitaScript)) { $fitaScript = Join-Path $env:TEMP "fita-client.ps1" $fitaUrl = "https://get.genaia.app/fita-client-powershell" Write-Host "[$(Get-Timestamp)] fita-client.ps1 not found locally - downloading from $fitaUrl" -ForegroundColor Cyan try { Invoke-WebRequest -Uri $fitaUrl -OutFile $fitaScript -UseBasicParsing Write-Host "[$(Get-Timestamp)] fita-client.ps1 downloaded to $fitaScript" -ForegroundColor Green } catch { throw "Failed to download fita-client.ps1: $_" } } Write-Host "[$(Get-Timestamp)] Starting fita tunnel (SOCKS5 port $socksPort)..." -ForegroundColor Cyan $script:FitaJob = Start-Job -ScriptBlock { param($scriptPath, $server, $token, $serverKey, $socksPort) & powershell.exe -ExecutionPolicy Bypass -NoProfile -File $scriptPath ` -Server $server -Token $token -ServerKey $serverKey -SocksPort $socksPort } -ArgumentList $fitaScript, $server, $token, $serverKey, $socksPort # Wait up to 30s for SOCKS5 to become available $deadline = (Get-Date).AddSeconds(30) while ((Get-Date) -lt $deadline) { # If job crashed already, dump its output immediately if ($script:FitaJob.State -eq 'Failed' -or $script:FitaJob.State -eq 'Completed') { $jobOut = Receive-Job $script:FitaJob 2>&1 | Out-String throw "fita-client.ps1 exited unexpectedly (state=$($script:FitaJob.State)). Output:`n$jobOut" } try { $testTcp = [System.Net.Sockets.TcpClient]::new() $t = $testTcp.ConnectAsync("127.0.0.1", $socksPort) Start-Sleep -Milliseconds 200 if ($t.IsCompleted -and -not $t.IsFaulted) { $testTcp.Dispose() Write-Host "[$(Get-Timestamp)] Fita SOCKS5 ready on port $socksPort" -ForegroundColor Green return $socksPort } $testTcp.Dispose() } catch { } Start-Sleep -Milliseconds 300 } # Timeout - dump job output for diagnostics $jobOut = Receive-Job $script:FitaJob 2>&1 | Out-String throw "Fita SOCKS5 did not start within 30 seconds. Job output:`n$jobOut" } # -- NATS Native Transport ----------------------------------------------------- # Native PowerShell implementation of NATS protocol over WebSocket. # NATS over WebSocket uses the same text protocol as TCP NATS: # Server -> Client: # INFO {...}\r\n # MSG [reply] \r\n\r\n # PING\r\n # +OK\r\n # -ERR '...'\r\n # Client -> Server: # CONNECT {...}\r\n # SUB \r\n # PUB [reply] \r\n\r\n # PONG\r\n function Connect-Nats { param([string]$Url) $script:NatsCancelSource = [System.Threading.CancellationTokenSource]::new() $script:NatsCancelToken = $script:NatsCancelSource.Token $script:NatsWebSocket = [System.Net.WebSockets.ClientWebSocket]::new() $script:NatsWebSocket.Options.KeepAliveInterval = [TimeSpan]::FromSeconds(30) if ($script:Config -and $script:Config.proxy -and $script:Config.proxy.url -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.url)) { $proxyUri = [System.Uri]::new($script:Config.proxy.url) $proxy = [System.Net.WebProxy]::new($proxyUri) if ($script:Config.proxy.username -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.username)) { $secPwd = ConvertTo-SecureString $script:Config.proxy.password -AsPlainText -Force $proxy.Credentials = [System.Net.NetworkCredential]::new($script:Config.proxy.username, $secPwd) } $script:NatsWebSocket.Options.Proxy = $proxy } Write-Host "[$(Get-Timestamp)] NATS: connecting to $Url" -ForegroundColor Cyan $connectTask = $script:NatsWebSocket.ConnectAsync([System.Uri]::new($Url), $script:NatsCancelToken) $deadline = (Get-Date).AddSeconds(15) while (-not $connectTask.IsCompleted) { if ((Get-Date) -gt $deadline) { throw "NATS: WebSocket connect timed out" } Start-Sleep -Milliseconds 50 } if ($connectTask.IsFaulted) { throw "NATS WebSocket connect failed: $($connectTask.Exception.InnerException.Message)" } Write-Host "[$(Get-Timestamp)] NATS: WebSocket connected" -ForegroundColor Green } function Send-NatsRaw { param([string]$Line) $bytes = [System.Text.Encoding]::UTF8.GetBytes($Line) $segment = [System.ArraySegment[byte]]::new($bytes) $task = $script:NatsWebSocket.SendAsync($segment, [System.Net.WebSockets.WebSocketMessageType]::Text, $true, $script:NatsCancelToken) while (-not $task.IsCompleted) { Start-Sleep -Milliseconds 10 } if ($task.IsFaulted) { throw "NATS send failed: $($task.Exception.InnerException.Message)" } } function Send-NatsConnect { param([string]$User, [string]$Pass) $connectJson = @{ verbose = $false pedantic = $false name = "ps-bridge" lang = "powershell" version = "1.0.0" user = $User pass = $Pass } | ConvertTo-Json -Compress Send-NatsRaw "CONNECT $connectJson`r`n" Write-Host "[$(Get-Timestamp)] NATS: CONNECT sent" -ForegroundColor Cyan } function Send-NatsSub { param([string]$Subject, [int]$Sid) Send-NatsRaw "SUB $Subject $Sid`r`n" $script:NatsSubscriptions[$Sid] = @{ subject = $Subject } Write-Host "[$(Get-Timestamp)] NATS: SUB $Subject (sid=$Sid)" -ForegroundColor Cyan } function Send-NatsPub { param([string]$Subject, [string]$Payload, [string]$ReplyTo = "") $payloadBytes = [System.Text.Encoding]::UTF8.GetByteCount($Payload) # Guard against exceeding the server-advertised max_payload (NATS default 1 MiB). # Without this check, Send-NatsRaw would publish the oversized frame anyway, the # server would reject it with "-ERR 'Maximum Payload Violation'" and close the # connection outright - the RPC response is lost, the caller (mcp-router) sees # nothing and has to wait out its own timeout, and this bridge has to reconnect. # Instead, replace the response with a small, explicit error so the caller gets # an immediate, actionable failure instead of a silent timeout. if ($payloadBytes -gt $script:NatsMaxPayload) { Write-Host "[$(Get-Timestamp)] NATS PUB -> $Subject REJECTED: $payloadBytes bytes exceeds max_payload=$($script:NatsMaxPayload)" -ForegroundColor Red $callId = $null try { $callId = ($Payload | ConvertFrom-Json).payload.callId } catch {} if ($callId) { $errPayload = @{ kind = "response" payload = @{ callId = $callId success = $false error = @{ message = "Tool result too large: $payloadBytes bytes exceeds NATS max_payload of $($script:NatsMaxPayload) bytes. Narrow the request (e.g. smaller limit/page size) and retry." } } } | ConvertTo-Json -Depth 5 -Compress $errBytes = [System.Text.Encoding]::UTF8.GetByteCount($errPayload) Write-Host "[$(Get-Timestamp)] NATS PUB -> $Subject (error substitute, $errBytes bytes)" -ForegroundColor DarkCyan if ($ReplyTo) { Send-NatsRaw "PUB $Subject $ReplyTo $errBytes`r`n$errPayload`r`n" } else { Send-NatsRaw "PUB $Subject $errBytes`r`n$errPayload`r`n" } } else { # No callId to reply to (e.g. a chunk/heartbeat, not a final response) - drop it. # Publishing it would still violate max_payload and close the connection. Write-Host "[$(Get-Timestamp)] NATS PUB -> $Subject dropped (no callId to attach error to)" -ForegroundColor Yellow } return } Write-Host "[$(Get-Timestamp)] NATS PUB -> $Subject ($payloadBytes bytes)" -ForegroundColor DarkCyan if ($ReplyTo) { Send-NatsRaw "PUB $Subject $ReplyTo $payloadBytes`r`n$Payload`r`n" } else { Send-NatsRaw "PUB $Subject $payloadBytes`r`n$Payload`r`n" } } function Send-NatsPong { Send-NatsRaw "PONG`r`n" } function Receive-NatsChunk { # ReceiveAsync must NOT be cancelled mid-flight - cancelling a ClientWebSocket receive # causes the connection to close with an auth/protocol error. # Instead, start a non-cancellable ReceiveAsync and wait up to 200ms for it to complete. # If it does not complete in time, return immediately (no data yet) and leave the task # running - on the next call it will be picked up from $script:NatsPendingRecv. if (-not $script:NatsPendingRecv) { $recvBuffer = [byte[]]::new(65536) $segment = [System.ArraySegment[byte]]::new($recvBuffer) $script:NatsPendingRecv = @{ task = $script:NatsWebSocket.ReceiveAsync($segment, $script:NatsCancelToken) buffer = $recvBuffer } } $recv = $script:NatsPendingRecv $task = $recv.task $deadline = (Get-Date).AddMilliseconds(200) while (-not $task.IsCompleted) { if ((Get-Date) -gt $deadline) { # Task still pending - return without data, try again next iteration return $true } Start-Sleep -Milliseconds 10 } # Task completed - consume it $script:NatsPendingRecv = $null if ($task.IsFaulted -or $task.IsCanceled) { return $false } $result = $task.Result if ($result.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { return $false } if ($result.Count -gt 0) { $script:NatsReadBuffer += [System.Text.Encoding]::UTF8.GetString($recv.buffer, 0, $result.Count) } return $true } function Read-NatsLine { # Returns the next complete \r\n-terminated line from the buffer, or $null if none yet. $idx = $script:NatsReadBuffer.IndexOf("`r`n") if ($idx -lt 0) { return $null } $line = $script:NatsReadBuffer.Substring(0, $idx) $script:NatsReadBuffer = $script:NatsReadBuffer.Substring($idx + 2) return $line } function Read-NatsBytes { # Reads exactly $Count bytes from the buffer (for MSG payload). param([int]$Count) # +2 for the trailing \r\n after the payload while ($script:NatsReadBuffer.Length -lt ($Count + 2)) { if (-not (Receive-NatsChunk)) { return $null } } $payload = $script:NatsReadBuffer.Substring(0, $Count) $script:NatsReadBuffer = $script:NatsReadBuffer.Substring($Count + 2) return $payload } function Receive-NatsMessage { # Reads and parses one NATS protocol message. # Returns a hashtable: @{ op; subject; sid; replyTo; payload; info } # op values: INFO, MSG, PING, OK, ERR, TIMEOUT (no data within 200ms), CLOSED $deadline = (Get-Date).AddMilliseconds(200) while ($true) { $line = Read-NatsLine if ($line -ne $null) { break } if ((Get-Date) -gt $deadline) { return @{ op = "TIMEOUT" } } if (-not (Receive-NatsChunk)) { return @{ op = "CLOSED" } } } if ($line.StartsWith("INFO ")) { $json = $line.Substring(5).Trim() return @{ op = "INFO"; info = ($json | ConvertFrom-Json) } } if ($line -eq "PONG") { return @{ op = "PONG" } } if ($line -eq "PING") { return @{ op = "PING" } } if ($line -eq "+OK") { return @{ op = "OK" } } if ($line.StartsWith("-ERR")) { return @{ op = "ERR"; error = $line } } if ($line.StartsWith("MSG ")) { $parts = $line.Substring(4).Trim() -split '\s+' $subject = $parts[0] $sid = [int]$parts[1] $replyTo = "" $bytes = 0 if ($parts.Count -eq 4) { $replyTo = $parts[2] $bytes = [int]$parts[3] } else { $bytes = [int]$parts[2] } $payload = Read-NatsBytes -Count $bytes if ($null -eq $payload) { return @{ op = "CLOSED" } } return @{ op = "MSG"; subject = $subject; sid = $sid; replyTo = $replyTo; payload = $payload } } return @{ op = "UNKNOWN"; raw = $line } } function Handle-NatsMsgRpc { param([hashtable]$Msg) $replyTo = $Msg.replyTo if (-not $replyTo) { Write-Host "[$(Get-Timestamp)] NATS MSG has no replyTo, skipping" -ForegroundColor DarkGray return } $callId = $null try { $envelope = $Msg.payload | ConvertFrom-Json Write-Host "[$(Get-Timestamp)] NATS MSG decoded: kind=$($envelope.kind) method=$($envelope.payload.method) callId=$($envelope.payload.callId)" -ForegroundColor Cyan if ($envelope.kind -ne "request") { return } $callId = $envelope.payload.callId $method = $envelope.payload.method $params = $envelope.payload.params Write-Host "[$(Get-Timestamp)] NATS RPC callId=$callId method=$method" -ForegroundColor Magenta if ($method -ne "executeTool") { $resp = @{ kind = "response"; payload = @{ callId = $callId; success = $false; error = @{ message = "Unknown method: $method" } } } Send-NatsPub -Subject $replyTo -Payload ($resp | ConvertTo-Json -Depth 5 -Compress) return } $toolName = $params.tool $args = $params.args Write-Host "[$(Get-Timestamp)] NATS executeTool (async): $toolName (callId: $callId)" -ForegroundColor Magenta # Extract injectedEnv from RPC metadata (set by mcp-router for shell tools) $injectedEnv = $null if ($envelope.payload.metadata -and $envelope.payload.metadata.injectedEnv) { $injectedEnv = $envelope.payload.metadata.injectedEnv } try { $timeoutSec = Get-ToolTimeout -Name $toolName $async = Start-ToolAsync -Name $toolName -Parameters $args -InjectedEnv $injectedEnv $script:PendingJobs[$callId] = @{ ps = $async.ps runspace = $async.runspace asyncResult = $async.asyncResult replyTo = $replyTo startTime = (Get-Date) timeoutSeconds = $timeoutSec toolName = $toolName } Write-Host "[$(Get-Timestamp)] Tool async started: $toolName callId=$callId timeout=${timeoutSec}s" -ForegroundColor DarkCyan } catch { $resp = @{ kind = "response" payload = @{ callId = $callId success = $false error = @{ message = $_.Exception.Message } } } Send-NatsPub -Subject $replyTo -Payload ($resp | ConvertTo-Json -Depth 5 -Compress) } } catch { Write-Host "[$(Get-Timestamp)] NATS MSG parse error: $_" -ForegroundColor Red if ($callId -and $replyTo) { $errResp = @{ kind = "response"; payload = @{ callId = $callId; success = $false; error = @{ message = "PS error: $($_.Exception.Message)" } } } try { Send-NatsPub -Subject $replyTo -Payload ($errResp | ConvertTo-Json -Depth 5 -Compress) } catch {} } } } function Process-PendingJobs { # Called in the NATS loop each iteration. Checks all pending runspace invocations # for completion or timeout, sends NATS response when done. if ($script:PendingJobs.Count -eq 0) { return } $completedCallIds = @() foreach ($callId in @($script:PendingJobs.Keys)) { $entry = $script:PendingJobs[$callId] $ps = $entry.ps $runspace = $entry.runspace $asyncResult = $entry.asyncResult $elapsed = ((Get-Date) - $entry.startTime).TotalSeconds $timedOut = $elapsed -gt $entry.timeoutSeconds $isDone = $asyncResult.IsCompleted -or $timedOut if (-not $isDone) { continue } $replyTo = $entry.replyTo $toolName = $entry.toolName if ($timedOut -and -not $asyncResult.IsCompleted) { Write-Host "[$(Get-Timestamp)] Tool TIMEOUT: $toolName callId=$callId after $([math]::Round($elapsed,1))s" -ForegroundColor Yellow try { $ps.Stop() } catch {} $resp = @{ kind = "response" payload = @{ callId = $callId success = $false error = @{ message = "Tool execution timed out after $($entry.timeoutSeconds) seconds (tool: $toolName)" } } } } else { # Completed - collect result try { $output = $ps.EndInvoke($asyncResult) # Handler returns a single hashtable - take last output item $toolResult = $output | Select-Object -Last 1 if ($ps.HadErrors) { $errMsg = ($ps.Streams.Error | Select-Object -Last 1).Exception.Message Write-Host "[$(Get-Timestamp)] Tool ERROR: $toolName callId=$callId error=$errMsg" -ForegroundColor Red $resp = @{ kind = "response" payload = @{ callId = $callId success = $false error = @{ message = if ($errMsg) { $errMsg } else { "Tool execution failed" } } } } } else { Write-Host "[$(Get-Timestamp)] Tool DONE: $toolName callId=$callId elapsed=$([math]::Round($elapsed,1))s" -ForegroundColor Green $resp = @{ kind = "response" payload = @{ callId = $callId success = $true result = $toolResult } } # If the tool requested a shutdown (e.g. update_bridge), signal main process if ($toolResult -and $toolResult.structuredContent -and $toolResult.structuredContent.triggerShutdown) { $script:WaitingForNewInstance = $true $script:UpdateStartTime = Get-Date Write-Host "[$(Get-Timestamp)] Shutdown requested by tool: $toolName" -ForegroundColor Yellow } } } catch { Write-Host "[$(Get-Timestamp)] Tool EXCEPTION: $toolName callId=$callId : $_" -ForegroundColor Red $resp = @{ kind = "response" payload = @{ callId = $callId success = $false error = @{ message = $_.Exception.Message } } } } } # Cleanup runspace try { $ps.Dispose() } catch {} try { $runspace.Close() } catch {} try { $runspace.Dispose() } catch {} $completedCallIds += $callId try { Send-NatsPub -Subject $replyTo -Payload ($resp | ConvertTo-Json -Depth 20 -Compress) Write-Host "[$(Get-Timestamp)] NATS response sent for callId=$callId" -ForegroundColor DarkGray } catch { Write-Host "[$(Get-Timestamp)] Failed to send response for callId=$callId : $_" -ForegroundColor Red } } foreach ($callId in $completedCallIds) { $script:PendingJobs.Remove($callId) } } function Start-NatsLoop { param([string]$BridgeSubject) Write-Host "[$(Get-Timestamp)] === NATS NATIVE TRANSPORT ===" -ForegroundColor Cyan Write-Host "[$(Get-Timestamp)] URL: $script:NatsUrl" -ForegroundColor Cyan Write-Host "[$(Get-Timestamp)] Subject: $BridgeSubject" -ForegroundColor Cyan Connect-Nats -Url $script:NatsUrl # Wait for INFO $infoMsg = $null $deadline = (Get-Date).AddSeconds(10) while ($null -eq $infoMsg) { if ((Get-Date) -gt $deadline) { throw "NATS: timed out waiting for INFO" } if (-not (Receive-NatsChunk)) { throw "NATS: connection closed before INFO" } $infoMsg = Read-NatsLine } if (-not $infoMsg.StartsWith("INFO ")) { throw "NATS: expected INFO, got: $infoMsg" } $infoJson = $infoMsg.Substring(5).Trim() | ConvertFrom-Json # Server-advertised max_payload (bytes). Falls back to NATS' own default (1 MiB) # if the field is ever missing - never 0/unset, which would make every PUB "too large". $script:NatsMaxPayload = if ($infoJson.max_payload) { [int64]$infoJson.max_payload } else { 1048576 } Write-Host "[$(Get-Timestamp)] NATS INFO received (max_payload=$($script:NatsMaxPayload))" -ForegroundColor Green # Send CONNECT (bridgeId as user, apiKey as pass) Send-NatsConnect -User $script:BridgeId -Pass $script:Config.apiKey # Send PING - server responds with PONG when auth callout succeeds Send-NatsRaw "PING`r`n" # Wait for PONG (auth OK) $deadline = (Get-Date).AddSeconds(15) $authenticated = $false while (-not $authenticated) { if ((Get-Date) -gt $deadline) { throw "NATS: timed out waiting for PONG after CONNECT+PING" } $msg = Receive-NatsMessage if ($msg.op -eq "PONG") { $authenticated = $true Write-Host "[$(Get-Timestamp)] NATS: authenticated (PONG received)" -ForegroundColor Green } elseif ($msg.op -eq "PING") { Send-NatsPong } elseif ($msg.op -eq "ERR") { throw "NATS: auth error: $($msg.error)" } elseif ($msg.op -eq "CLOSED") { throw "NATS: connection closed during auth" } } # Subscribe to bridge subject $sid = $script:NatsSidCounter++ Send-NatsSub -Subject $BridgeSubject -Sid $sid # Drain any immediate -ERR on subscription Start-Sleep -Milliseconds 300 $errCheck = Receive-NatsMessage if ($errCheck.op -eq "ERR") { throw "NATS: SUB rejected: $($errCheck.error). Check NATS auth callout permissions for bridgeId=$($script:BridgeId)" } Write-Host "[$(Get-Timestamp)] NATS: ready, listening on $BridgeSubject" -ForegroundColor Green $msgCount = 0 while ($script:NatsWebSocket.State -eq [System.Net.WebSockets.WebSocketState]::Open) { # Check if self-update was triggered - wait for new instance to register, then exit if ($script:WaitingForNewInstance) { $waitingSecs = ((Get-Date) - $script:UpdateStartTime).TotalSeconds # Wait up to 120s for new instance to appear in Supabase if ($waitingSecs -lt 120) { try { $bridgeItemId = "bridge-$($script:UserId)-$($script:BridgeId)" $headers = @{ "Authorization" = "Bearer $script:AuthToken" "apikey" = $script:SupabaseAnonKey } $invokeParams = @{ Uri = "$script:SupabaseUrl/rest/v1/items?id=eq.$bridgeItemId&select=computedData"; Headers = $headers; UseBasicParsing = $true } if ($script:Config.proxy -and $script:Config.proxy.url -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.url)) { $invokeParams.Proxy = $script:Config.proxy.url if ($script:Config.proxy.username -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.username)) { $securePassword = ConvertTo-SecureString $script:Config.proxy.password -AsPlainText -Force $invokeParams.ProxyCredential = [System.Management.Automation.PSCredential]::new($script:Config.proxy.username, $securePassword) } } $resp = Invoke-RestMethod @invokeParams $lastSeen = $resp[0].computedData.lastSeen if ($lastSeen) { $lastSeenDate = [DateTime]::Parse($lastSeen) if ($lastSeenDate -gt $script:UpdateStartTime) { Write-Host "[$(Get-Timestamp)] New instance confirmed (lastSeen=$lastSeen), shutting down." -ForegroundColor Green return } } } catch {} # Not yet - keep running } else { Write-Host "[$(Get-Timestamp)] Self-update: timeout waiting for new instance (120s), force shutdown." -ForegroundColor Yellow return } } # Process completed/timed-out async tool jobs Process-PendingJobs $msg = Receive-NatsMessage switch ($msg.op) { "MSG" { $msgCount++ Write-Host "[$(Get-Timestamp)] NATS MSG #$msgCount sub=$($msg.subject) replyTo=$($msg.replyTo)" -ForegroundColor White Handle-NatsMsgRpc -Msg $msg } "PING" { Send-NatsPong Write-Host "[$(Get-Timestamp)] NATS PONG sent" -ForegroundColor DarkGray } "ERR" { Write-Host "[$(Get-Timestamp)] NATS ERR: $($msg.error)" -ForegroundColor Red } "CLOSED" { Write-Host "[$(Get-Timestamp)] NATS: connection closed" -ForegroundColor Yellow return } "TIMEOUT" { # No message in 200ms - continue loop } } } } # -- Tool Implementations ------------------------------------------------------ function Execute-PingTool { param([object]$Parameters) Write-Host "[$(Get-Timestamp)] Executing ping tool" -ForegroundColor Yellow $timestamp = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffZ") return @{ content = "Pong! $timestamp" structuredContent = @{ timestamp = $timestamp; message = "pong" } } } function Execute-PowerShellTool { param([object]$Parameters) Write-Host "[$(Get-Timestamp)] Executing PowerShell script tool" -ForegroundColor Yellow try { $script = "" if ($Parameters) { if ($Parameters -is [hashtable]) { $script = if ($Parameters.script) { $Parameters.script } else { "" } } elseif ($Parameters.script) { $script = $Parameters.script } } if ([string]::IsNullOrWhiteSpace($script)) { throw "No script provided" } Write-Host "[$(Get-Timestamp)] Executing script: $($script.Substring(0, [Math]::Min(100, $script.Length)))..." -ForegroundColor Cyan $output = Invoke-Expression $script 2>&1 | Out-String if ([string]::IsNullOrWhiteSpace($output)) { $output = "(no output)" } Write-Host "[$(Get-Timestamp)] PowerShell script executed" -ForegroundColor Green return @{ content = $output structuredContent = @{ output = $output; exitCode = 0 } } } catch { Write-Host "[$(Get-Timestamp)] PowerShell script execution failed: $_" -ForegroundColor Red return @{ content = "Script execution failed: $($_.Exception.Message)" structuredContent = @{ error = $_.Exception.Message } } } } function Get-CalendarItems { param( [DateTime]$StartTime = (Get-Date).AddDays(-30), [DateTime]$EndTime = (Get-Date).AddDays(30), [string[]]$OnlySubjects = @(), [string[]]$ExcludeSubjects = @(), [bool]$ResolveParticipants = $true, [int]$Limit = 0, # 0 = no limit [int]$Offset = 0 # skip first N matching events ) $outlook = New-Object -ComObject Outlook.Application $calendar = $outlook.Session.GetDefaultFolder(9) $items = $calendar.Items $items.Sort("[Start]") $items.IncludeRecurrences = $true $filterString = "[Start] >= '$($StartTime.ToString('g'))' AND [End] <= '$($EndTime.ToString('g'))'" $events = @() $matchIndex = 0 foreach ($item in $items.Restrict($filterString)) { if ($OnlySubjects.Count -gt 0) { $match = $false foreach ($pattern in $OnlySubjects) { if ($item.Subject -like "*$pattern*") { $match = $true; break } } if (-not $match) { continue } } if ($ExcludeSubjects.Count -gt 0) { $exclude = $false foreach ($pattern in $ExcludeSubjects) { if ($item.Subject -like "*$pattern*") { $exclude = $true; break } } if ($exclude) { continue } } # Apply offset/limit if ($matchIndex -lt $Offset) { $matchIndex++; continue } if ($Limit -gt 0 -and ($matchIndex - $Offset) -ge $Limit) { break } # Extract participants from Recipients collection # Recipient.Type: 1=required, 2=optional, 3=resource # Only resolve Exchange addresses for first 20 recipients to avoid slow GAL lookups. # Results are cached in a JSON file so they persist across bridge restarts and runspaces. $maxParticipants = 20 $participantsCount = 0 $participants = @() try { $participantsCount = $item.Recipients.Count if ($ResolveParticipants) { # Load cache once per event batch (lazy, in this runspace scope) if (-not $script:_galCache) { $script:_galCache = Read-ExchangeAddressCache $script:_galCacheDirty = $false } $i = 0 foreach ($r in $item.Recipients) { if ($i -ge $maxParticipants) { break } $i++ $rType = switch ($r.Type) { 2 { "optional" } 3 { "resource" } default { "required" } } # Check cache first (keyed by display name) $cacheKey = $r.Name if ($script:_galCache.ContainsKey($cacheKey)) { $rAddress = $script:_galCache[$cacheKey] } else { $rAddress = try { $r.AddressEntry.GetExchangeUser().PrimarySmtpAddress } catch { "" } if (-not $rAddress) { $rAddress = try { $r.Address } catch { "" } } # Normalize Exchange DN-style addresses (start with /o=) to empty if ($rAddress -match '^/o=') { $rAddress = "" } # Store in cache $script:_galCache[$cacheKey] = $rAddress $script:_galCacheDirty = $true } $participants += [pscustomobject]@{ name = $r.Name address = $rAddress.ToLower() type = $rType } } } } catch { # Recipients not accessible on this item type - ignore } $htmlBody = try { $item.HTMLBody } catch { "" } $meetingUrl = $null if ($htmlBody -match 'https://teams\.microsoft\.com/l/meetup-join/[^"'' <>]+') { $meetingUrl = $Matches[0] } elseif ($htmlBody -match 'https://[^/]*\.zoom\.us/j/[^"'' <>]+') { $meetingUrl = $Matches[0] } elseif ($htmlBody -match 'https://meet\.google\.com/[^"'' <>]+') { $meetingUrl = $Matches[0] } elseif ($htmlBody -match 'https://chime\.aws/[^"'' <>]+') { $meetingUrl = $Matches[0] } # Fallback: some organizers put the meeting link directly in Location instead of (or # alongside) HTMLBody, often mixed with a physical room, e.g. # "CONF PL WAW13 09.505 (6); https://amazon.zoom.us/j/98286740048?pwd=...&from=addon". # Only checked when HTMLBody yielded nothing - Temporal's extractMeetingFields() still # owns splitting Location into location+meetingUrl for the UI/DB fields; this is purely # about making sure MeetingUrl itself isn't silently empty when it's only in Location. if (-not $meetingUrl) { $rawLocation = try { $item.Location } catch { "" } if ($rawLocation -match 'https://teams\.microsoft\.com/l/meetup-join/[^"'' <>]+') { $meetingUrl = $Matches[0] } elseif ($rawLocation -match 'https://[^/]*\.zoom\.us/j/[^"'' <>]+') { $meetingUrl = $Matches[0] } elseif ($rawLocation -match 'https://meet\.google\.com/[^"'' <>]+') { $meetingUrl = $Matches[0] } elseif ($rawLocation -match 'https://chime\.aws/[^"'' <>]+') { $meetingUrl = $Matches[0] } } $organizer = try { $item.Organizer } catch { $null } $events += [pscustomobject]@{ Subject = $item.Subject Start = $item.Start.ToString("o") End = $item.End.ToString("o") Location = $item.Location Body = $item.Body BusyStatus = $item.BusyStatus EntryId = $item.EntryId # For recurring events, each occurrence has the same EntryId. # Append Start to create a unique ExternalId per occurrence. ExternalId = if ($item.IsRecurring) { "$($item.EntryId)-$($item.Start.ToString('o'))" } else { $item.EntryId } AllDay = $item.AllDayEvent IsRecurring = $item.IsRecurring Organizer = $organizer Participants = $participants ParticipantsCount = $participantsCount MeetingUrl = $meetingUrl } $matchIndex++ } # Flush GAL address cache to disk if any new entries were added if ($ResolveParticipants -and $script:_galCacheDirty) { Write-ExchangeAddressCache -Cache $script:_galCache $script:_galCacheDirty = $false } return $events } function Execute-GetCalendarItemsTool { param([object]$Parameters) Write-Host "[$(Get-Timestamp)] Executing calendar_get_items tool" -ForegroundColor Yellow try { $startDays = if ($Parameters.startDays) { [int]$Parameters.startDays } else { -7 } $endDays = if ($Parameters.endDays) { [int]$Parameters.endDays } else { 7 } $onlySubjects = if ($Parameters.onlySubjects) { $Parameters.onlySubjects } else { @() } $excludeSubjects= if ($Parameters.excludeSubjects){ $Parameters.excludeSubjects } else { @() } $resolveParticipants = if ($null -ne $Parameters.resolveParticipants) { [bool]$Parameters.resolveParticipants } else { $true } $limit = if ($Parameters.limit) { [int]$Parameters.limit } else { 0 } $offset = if ($Parameters.offset) { [int]$Parameters.offset } else { 0 } $startTime = (Get-Date).AddDays($startDays) $endTime = (Get-Date).AddDays($endDays) Write-Host "[$(Get-Timestamp)] Getting calendar events $($startTime.ToString('yyyy-MM-dd')) to $($endTime.ToString('yyyy-MM-dd'))" -ForegroundColor Cyan $events = Get-CalendarItems -StartTime $startTime -EndTime $endTime -OnlySubjects $onlySubjects -ExcludeSubjects $excludeSubjects -ResolveParticipants $resolveParticipants -Limit $limit -Offset $offset if (-not $events) { $events = @() } Write-Host "[$(Get-Timestamp)] calendar_get_items: got $($events.Count) events from Outlook (range: $($startTime.ToString('yyyy-MM-dd')) to $($endTime.ToString('yyyy-MM-dd')), resolveParticipants=$resolveParticipants, limit=$limit, offset=$offset)" -ForegroundColor Magenta $eventList = @() foreach ($event in $events) { if (-not $event.Start -or -not $event.End) { continue } $startFmt = [DateTime]::Parse($event.Start).ToString('yyyy-MM-dd HH:mm') $endFmt = [DateTime]::Parse($event.End).ToString('HH:mm') $eventList += "$startFmt - $($endFmt): $($event.Subject)" } $content = if ($eventList.Count -gt 0) { "Found $($events.Count) calendar events:`n" + ($eventList -join "`n") } else { "No calendar events found in the specified time range." } return @{ content = $content structuredContent = @{ # @(...) forces an array even when $events was unwrapped to a scalar # by PowerShell's single-element-array-return gotcha (Get-CalendarItems # returns $events built via @() + foreach-loop +=, which PowerShell # collapses to a bare object when it holds exactly one item). events = @($events) count = $events.Count offset = $offset limit = $limit timeRange = @{ start = $startTime.ToString('o'); end = $endTime.ToString('o') } } } } catch { Write-Host "[$(Get-Timestamp)] Calendar query failed: $_" -ForegroundColor Red return @{ content = "Calendar query failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } function Add-CalendarEvent { param( [string]$Subject, [DateTime]$Start, [DateTime]$End, [string]$Body = "", [bool]$AllDay = $false, [int]$BusyStatus = 2, [string]$Location = "", [int]$ReminderMinutes = -1 # -1 = leave Outlook's own default reminder behavior untouched ) $outlook = New-Object -ComObject Outlook.Application $appointment = $outlook.CreateItem(1) $appointment.Subject = $Subject $appointment.Start = $Start $appointment.End = $End $appointment.Body = $Body $appointment.AllDayEvent= $AllDay $appointment.BusyStatus = $BusyStatus $appointment.Location = $Location if ($ReminderMinutes -ge 0) { # Outlook's AppointmentItem defaults ReminderSet=True with the user's default # reminder lead time (typically 15 min) on every newly created item - callers # that want no reminder at all must explicitly disable it, there is no "leave # unset" state once CreateItem(1) has run. if ($ReminderMinutes -eq 0) { $appointment.ReminderSet = $false } else { $appointment.ReminderSet = $true $appointment.ReminderMinutesBeforeStart = $ReminderMinutes } } $appointment.Save() return $appointment.EntryId } function Update-CalendarEvent { param( [string]$EntryId, [string]$Subject, [DateTime]$Start, [DateTime]$End, [string]$Body, [bool]$AllDay, [int]$BusyStatus, [string]$Location ) $outlook = New-Object -ComObject Outlook.Application try { $item = $outlook.Session.GetItemFromID($EntryId) if ($Subject) { $item.Subject = $Subject } if ($Start) { $item.Start = $Start } if ($End) { $item.End = $End } if ($Body) { $item.Body = $Body } if ($PSBoundParameters.ContainsKey('AllDay')) { $item.AllDayEvent = $AllDay } if ($PSBoundParameters.ContainsKey('BusyStatus')) { $item.BusyStatus = $BusyStatus } if ($PSBoundParameters.ContainsKey('Location')) { $item.Location = $Location } $item.Save() return $true } catch { Write-Error "Failed to update calendar event: $_" return $false } } function Remove-CalendarEvent { param([string]$EntryId) $outlook = New-Object -ComObject Outlook.Application try { $item = $outlook.Session.GetItemFromID($EntryId) $item.Delete() return $true } catch { Write-Error "Failed to remove calendar event: $_" return $false } } function Remove-OldEBusyEvents { $outlook = New-Object -ComObject Outlook.Application $calendar = $outlook.Session.GetDefaultFolder(9) $today = (Get-Date).Date foreach ($item in @() + $calendar.Items) { if ($item.Subject -like "EBusy*" -and $item.End -lt $today) { Write-Host "[$(Get-Timestamp)] Deleting old EBusy event: $($item.Subject)" $item.Delete() } } } function Get-MailList { param( [int]$Count = 50, [string[]]$OnlySubjects = @(), [string[]]$ExcludeSubjects = @(), [string[]]$OnlySenders = @(), [string[]]$ExcludeSenders = @(), [DateTime]$StartDate = (Get-Date).AddDays(-7).Date, [DateTime]$EndDate = (Get-Date).Date.AddDays(1).AddSeconds(-1) ) $outlook = New-Object -ComObject Outlook.Application $inbox = $outlook.Session.GetDefaultFolder(6) $mails = @() foreach ($item in $inbox.Items) { if ($item.ReceivedTime -lt $StartDate -or $item.ReceivedTime -gt $EndDate) { continue } if ($OnlySubjects.Count -gt 0) { $match = $false foreach ($p in $OnlySubjects) { if ($item.Subject -like "*$p*") { $match = $true; break } } if (-not $match) { continue } } if ($ExcludeSubjects.Count -gt 0) { $exclude = $false foreach ($p in $ExcludeSubjects) { if ($item.Subject -like "*$p*") { $exclude = $true; break } } if ($exclude) { continue } } if ($OnlySenders.Count -gt 0) { $match = $false foreach ($p in $OnlySenders) { if ($item.SenderName -like "*$p*" -or $item.SenderEmailAddress -like "*$p*") { $match = $true; break } } if (-not $match) { continue } } if ($ExcludeSenders.Count -gt 0) { $exclude = $false foreach ($p in $ExcludeSenders) { if ($item.SenderName -like "*$p*" -or $item.SenderEmailAddress -like "*$p*") { $exclude = $true; break } } if ($exclude) { continue } } $mails += [pscustomobject]@{ Subject = $item.Subject SenderName = $item.SenderName SenderEmailAddress = $item.SenderEmailAddress ReceivedTime = $item.ReceivedTime EntryId = $item.EntryId StoreId = $item.Parent.StoreID Size = $item.Size UnRead = $item.UnRead } if ($mails.Count -ge $Count) { break } } return $mails } function Find-MailByEntryId { # Finds a mail item by EntryId by searching inbox.Items directly. # More reliable than GetItemFromID in Exchange Cached Mode environments. param([string]$EntryId) $outlook = New-Object -ComObject Outlook.Application $inbox = $outlook.Session.GetDefaultFolder(6) # Try GetItemFromID first (fast path) try { $item = $outlook.Session.GetItemFromID($EntryId) if ($item) { return $item } } catch {} # Fallback: scan inbox items by EntryId foreach ($item in $inbox.Items) { try { if ($item.EntryId -eq $EntryId) { return $item } } catch {} } # Also check Deleted Items try { $deleted = $outlook.Session.GetDefaultFolder(3) foreach ($item in $deleted.Items) { try { if ($item.EntryId -eq $EntryId) { return $item } } catch {} } } catch {} return $null } function Get-MailContent { param([string]$EntryId, [string]$StoreId) try { $item = Find-MailByEntryId -EntryId $EntryId if (-not $item) { return $null } return [pscustomobject]@{ Subject = $item.Subject SenderName = $item.SenderName SenderEmailAddress = $item.SenderEmailAddress ReceivedTime = $item.ReceivedTime Body = $item.Body HTMLBody = $item.HTMLBody Attachments = @($item.Attachments | ForEach-Object { $_.FileName }) } } catch { Write-Error "Failed to get mail content: $_" return $null } } function Remove-Mail { param([string]$EntryId, [string]$StoreId) try { $item = Find-MailByEntryId -EntryId $EntryId if (-not $item) { return $false } $item.Delete() return $true } catch { Write-Error "Failed to remove mail: $_" return $false } } function Cleanup-Mail { param([string[]]$SubjectPatterns = @(), [string[]]$SenderPatterns = @()) $outlook = New-Object -ComObject Outlook.Application $inbox = $outlook.Session.GetDefaultFolder(6) foreach ($item in @() + $inbox.Items) { $remove = $false foreach ($p in $SubjectPatterns) { if ($item.Subject -like "*$p*") { $remove = $true; break } } if (-not $remove) { foreach ($p in $SenderPatterns) { if ($item.SenderName -like "*$p*" -or $item.SenderEmailAddress -like "*$p*") { $remove = $true; break } } } if ($remove) { Write-Host "[$(Get-Timestamp)] Removing mail: $($item.Subject) from $($item.SenderEmailAddress)" $item.Delete() } } } function Get-MailFolders { # Returns flat list of all mail folders with name, path and EntryId $outlook = New-Object -ComObject Outlook.Application $result = [System.Collections.ArrayList]::new() # Use a queue: each item is @{ folder; path } $queue = [System.Collections.Queue]::new() foreach ($store in $outlook.Session.Stores) { try { $root = $store.GetRootFolder() foreach ($folder in $root.Folders) { $queue.Enqueue(@{ folder = $folder; path = $folder.Name }) } } catch {} } while ($queue.Count -gt 0) { $entry = $queue.Dequeue() $folder = $entry.folder $path = $entry.path $null = $result.Add([pscustomobject]@{ Name = $folder.Name Path = $path EntryId = $folder.EntryID }) foreach ($sub in $folder.Folders) { $queue.Enqueue(@{ folder = $sub; path = "$path\$($sub.Name)" }) } } return $result.ToArray() } function Move-MailToFolder { param([string]$EntryId, [string]$FolderPath) $outlook = New-Object -ComObject Outlook.Application # Find source mail $mail = Find-MailByEntryId -EntryId $EntryId if (-not $mail) { throw "Mail not found: $EntryId" } # Find destination folder by path (e.g. "Inbox\Subfolder" or just "Archive") $targetFolder = $null foreach ($store in $outlook.Session.Stores) { try { $root = $store.GetRootFolder() $parts = $FolderPath -split '\\' $current = $root foreach ($part in $parts) { $found = $null foreach ($f in $current.Folders) { if ($f.Name -eq $part) { $found = $f; break } } if (-not $found) { $current = $null; break } $current = $found } if ($current -and $current -ne $root) { $targetFolder = $current; break } } catch {} } if (-not $targetFolder) { throw "Folder not found: $FolderPath" } $mail.Move($targetFolder) | Out-Null return $true } function Search-Mails { param( [string]$Query, [string]$FolderPath = "Inbox", [int]$Count = 25, [string[]]$Fields = @("Subject", "Body", "SenderName", "SenderEmailAddress") ) $outlook = New-Object -ComObject Outlook.Application $result = [System.Collections.ArrayList]::new() # Locate folder $folder = $null foreach ($store in $outlook.Session.Stores) { try { $root = $store.GetRootFolder() if ($FolderPath -eq "" -or $FolderPath -eq $root.Name) { $folder = $root; break } $parts = $FolderPath -split '\\' $current = $root foreach ($part in $parts) { $found = $null foreach ($f in $current.Folders) { if ($f.Name -eq $part) { $found = $f; break } } if (-not $found) { $current = $null; break } $current = $found } if ($current -and $current -ne $root) { $folder = $current; break } } catch {} } if (-not $folder) { throw "Folder not found: $FolderPath" } $q = $Query.ToLower() foreach ($item in $folder.Items) { try { $matched = $false foreach ($field in $Fields) { $val = "" try { $val = ($item | Select-Object -ExpandProperty $field -ErrorAction SilentlyContinue) -as [string] } catch {} if ($val -and $val.ToLower().Contains($q)) { $matched = $true; break } } if ($matched) { $null = $result.Add([pscustomobject]@{ Subject = $item.Subject SenderName = $item.SenderName SenderEmailAddress = $item.SenderEmailAddress ReceivedTime = $item.ReceivedTime EntryId = $item.EntryId StoreId = $item.Parent.StoreID UnRead = $item.UnRead Size = $item.Size }) if ($result.Count -ge $Count) { break } } } catch {} } return $result.ToArray() } function Set-MailReadState { param([string]$EntryId, [bool]$MarkAsRead) $item = Find-MailByEntryId -EntryId $EntryId if (-not $item) { throw "Mail not found: $EntryId" } $item.UnRead = -not $MarkAsRead $item.Save() return $true } function Get-MailAttachmentsList { param([string]$EntryId) $item = Find-MailByEntryId -EntryId $EntryId if (-not $item) { throw "Mail not found: $EntryId" } $attachments = [System.Collections.ArrayList]::new() for ($i = 1; $i -le $item.Attachments.Count; $i++) { $att = $item.Attachments.Item($i) $null = $attachments.Add([pscustomobject]@{ Index = $i FileName = $att.FileName Size = $att.Size Type = $att.Type }) } return $attachments.ToArray() } function Save-MailAttachment { param([string]$EntryId, [int]$Index, [string]$SavePath) $item = Find-MailByEntryId -EntryId $EntryId if (-not $item) { throw "Mail not found: $EntryId" } if ($Index -lt 1 -or $Index -gt $item.Attachments.Count) { throw "Attachment index out of range: $Index (total: $($item.Attachments.Count))" } $att = $item.Attachments.Item($Index) if (-not $SavePath) { $SavePath = "$env:TEMP\$($att.FileName)" } $att.SaveAsFile($SavePath) return $SavePath } function Get-MailsBySubject { param([string]$SubjectPattern, [int]$Count = 50) $outlook = New-Object -ComObject Outlook.Application $inbox = $outlook.Session.GetDefaultFolder(6) $mails = @() foreach ($item in $inbox.Items) { if ($item.Subject -like "*$SubjectPattern*") { $mails += [pscustomobject]@{ Subject = $item.Subject SenderEmailAddress = $item.SenderEmailAddress ReceivedTime = $item.ReceivedTime EntryId = $item.EntryId } if ($mails.Count -ge $Count) { break } } } return $mails } function Get-MailItems { param( [DateTime]$StartDate = (Get-Date).AddDays(-7), [DateTime]$EndDate = (Get-Date).AddDays(1), [string[]]$ExcludeSubjects = @(), [string[]]$ExcludeSenders = @(), [bool]$FetchBody = $false, [int]$Limit = 50, # 0 = no limit [int]$Offset = 0 ) $outlook = New-Object -ComObject Outlook.Application $inbox = $outlook.Session.GetDefaultFolder(6) $items = $inbox.Items $items.Sort('[ReceivedTime]', $true) # Use Outlook Restrict filter for efficient date-range query (no full inbox scan) $startStr = $StartDate.ToString('g') $endStr = $EndDate.ToString('g') $filter = "[ReceivedTime] >= '$startStr' AND [ReceivedTime] <= '$endStr'" $restricted = $items.Restrict($filter) $result = @() $matchIndex = 0 foreach ($item in $restricted) { # Skip non-mail item types (meeting requests, calendar items in inbox etc.) $msgClass = try { $item.MessageClass } catch { '' } if ($msgClass -like 'IPM.Schedule.*') { continue } # Only process IPM.Note and IPM.Note.SMIME variants if ($msgClass -ne 'IPM.Note' -and $msgClass -ne 'IPM.Note.SMIME' -and $msgClass -ne 'IPM.Note.SMIME.MultipartSigned') { continue } # Subject filter - regex, matches ignorePatterns semantics validated client-side (new RegExp(val)) if ($ExcludeSubjects.Count -gt 0) { $exclude = $false foreach ($pattern in $ExcludeSubjects) { if ($item.Subject -match $pattern) { $exclude = $true; break } } if ($exclude) { continue } } # Sender filter - regex against SenderName or SenderEmailAddress (ignoreSenderPatterns) if ($ExcludeSenders.Count -gt 0) { $exclude = $false $senderName = try { $item.SenderName } catch { '' } $senderAddr = try { $item.SenderEmailAddress } catch { '' } foreach ($pattern in $ExcludeSenders) { if ($senderName -match $pattern -or $senderAddr -match $pattern) { $exclude = $true; break } } if ($exclude) { continue } } # Offset/limit if ($matchIndex -lt $Offset) { $matchIndex++; continue } if ($Limit -gt 0 -and ($matchIndex - $Offset) -ge $Limit) { break } $isEncrypted = $msgClass -eq 'IPM.Note.SMIME' # Extract RFC822 headers via MAPI PR_TRANSPORT_MESSAGE_HEADERS (0x007D001E) $headers = try { $item.PropertyAccessor.GetProperty('http://schemas.microsoft.com/mapi/proptag/0x007D001E') } catch { '' } $messageId = '' $inReplyTo = '' $references = '' $toHeader = '' $ccHeader = '' if ($headers) { if ($headers -match '(?m)^Message-ID:[ \t]*(.+)') { $messageId = $Matches[1].Trim() } if ($headers -match '(?m)^In-Reply-To:[ \t]*(.+)') { $inReplyTo = $Matches[1].Trim() } if ($headers -match '(?m)^References:[ \t]*(.+)') { $references = $Matches[1].Trim() } if ($headers -match '(?m)^To:[ \t]*(.+)') { $toHeader = $Matches[1].Trim() } if ($headers -match '(?m)^Cc:[ \t]*(.+)') { $ccHeader = $Matches[1].Trim() } } $body = $null $htmlBody = $null if ($FetchBody -and -not $isEncrypted) { $body = try { $item.Body } catch { $null } $htmlBody = try { $item.HTMLBody } catch { $null } } $convId = try { $item.ConversationID } catch { '' } $result += [pscustomobject]@{ Subject = $item.Subject SenderName = $item.SenderName SenderAddress = $item.SenderEmailAddress ReceivedTime = $item.ReceivedTime.ToString('o') EntryId = $item.EntryId ConversationID = $convId MessageId = $messageId InReplyTo = $inReplyTo References = $references To = $toHeader Cc = $ccHeader UnRead = $item.UnRead Size = $item.Size Encrypted = $isEncrypted Body = $body HTMLBody = $htmlBody } $matchIndex++ } return $result } function Read-RuleConditions { param($r) $c = @{} if ($r.Conditions.From.Enabled) { $c.from = @($r.Conditions.From.Recipients | ForEach-Object { $_.Address }) } if ($r.Conditions.SentTo.Enabled) { $c.sentTo = @($r.Conditions.SentTo.Recipients | ForEach-Object { $_.Address }) } if ($r.Conditions.Subject.Enabled) { $c.subjectContains = $r.Conditions.Subject.Text } if ($r.Conditions.Body.Enabled) { $c.bodyContains = $r.Conditions.Body.Text } if ($r.Conditions.BodyOrSubject.Enabled) { $c.bodyOrSubjectContains = $r.Conditions.BodyOrSubject.Text } if ($r.Conditions.HasAttachment.Enabled) { $c.hasAttachment = $true } if ($r.Conditions.ToMe.Enabled) { $c.toMe = $true } if ($r.Conditions.OnlyToMe.Enabled) { $c.onlyToMe = $true } if ($r.Conditions.ToOrCc.Enabled) { $c.toOrCc = $true } if ($r.Conditions.NotTo.Enabled) { $c.notTo = $true } if ($r.Conditions.CC.Enabled) { $c.cc = @($r.Conditions.CC.Recipients | ForEach-Object { $_.Address }) } if ($r.Conditions.Importance.Enabled) { $c.importance = $r.Conditions.Importance.Importance } if ($r.Conditions.Category.Enabled) { $c.category = $r.Conditions.Category.Categories } if ($r.Conditions.AnyCategory.Enabled) { $c.anyCategory = $true } if ($r.Conditions.SenderAddress.Enabled) { $c.senderAddress = $r.Conditions.SenderAddress.Address } if ($r.Conditions.RecipientAddress.Enabled) { $c.recipientAddress = $r.Conditions.RecipientAddress.Address } if ($r.Conditions.MeetingInviteOrUpdate.Enabled) { $c.meetingInviteOrUpdate = $true } if ($r.Conditions.MessageHeader.Enabled) { $c.messageHeader = $r.Conditions.MessageHeader.Text } if ($r.Conditions.SenderInAddressList.Enabled) { $c.senderInAddressList = @($r.Conditions.SenderInAddressList.AddressList | ForEach-Object { $_.Name }) } return $c } function Read-RuleActions { param($r) $a = @{} if ($r.Actions.MoveToFolder.Enabled) { $a.moveToFolder = $r.Actions.MoveToFolder.Folder.Name } if ($r.Actions.CopyToFolder.Enabled) { $a.copyToFolder = $r.Actions.CopyToFolder.Folder.Name } if ($r.Actions.MarkAsRead.Enabled) { $a.markAsRead = $true } if ($r.Actions.Delete.Enabled) { $a.deleteMessage = $true } if ($r.Actions.Forward.Enabled) { $a.forwardTo = @($r.Actions.Forward.Recipients | ForEach-Object { $_.Address }) } if ($r.Actions.Redirect.Enabled) { $a.redirectTo = @($r.Actions.Redirect.Recipients | ForEach-Object { $_.Address }) } return $a } function Get-InboxRules { $outlook = New-Object -ComObject Outlook.Application return $outlook.Session.DefaultStore.GetRules() } function Get-RuleByName { param([string]$Name) $rules = Get-InboxRules $count = 0 try { $count = $rules.Count } catch { return $null } for ($i = 1; $i -le $count; $i++) { try { $r = $rules.Item($i) if ($r.Name -eq $Name) { return @{ rule = $r; rules = $rules } } } catch { continue } } return $null } function Execute-SelfUpdateTool { param([object]$Parameters) Write-Host "[$(Get-Timestamp)] Starting self-update..." -ForegroundColor Cyan $defaultUpdateUrl = "https://get.genaia.app/bridge-powershell" $updateUrl = if ($script:Config.updateUrl -and -not [string]::IsNullOrWhiteSpace($script:Config.updateUrl)) { $script:Config.updateUrl } else { $defaultUpdateUrl } # If ScriptPath not set (bridge started from memory/iex), save to user profile $scriptPath = $script:ScriptPath if (-not $scriptPath) { $scriptPath = "$env:USERPROFILE\genaia-bridge.ps1" Write-Host "[$(Get-Timestamp)] ScriptPath not set, using: $scriptPath" -ForegroundColor Yellow } Write-Host "[$(Get-Timestamp)] Downloading update from: $updateUrl" -ForegroundColor Cyan $invokeParams = @{ Uri = $updateUrl; UseBasicParsing = $true } if ($script:Config.proxy -and $script:Config.proxy.url -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.url)) { $invokeParams.Proxy = $script:Config.proxy.url if ($script:Config.proxy.username -and -not [string]::IsNullOrWhiteSpace($script:Config.proxy.username)) { $securePassword = ConvertTo-SecureString $script:Config.proxy.password -AsPlainText -Force $invokeParams.ProxyCredential = [System.Management.Automation.PSCredential]::new($script:Config.proxy.username, $securePassword) } } $newContent = (Invoke-WebRequest @invokeParams).Content Write-Host "[$(Get-Timestamp)] Writing new version to: $scriptPath" -ForegroundColor Cyan [System.IO.File]::WriteAllText($scriptPath, $newContent, [System.Text.Encoding]::UTF8) $absConfigPath = if ($ConfigPath -and $ConfigPath -ne "config.json") { $ConfigPath } else { "$env:USERPROFILE\genaia-bridge-config.json" } Write-Host "[$(Get-Timestamp)] Starting new instance..." -ForegroundColor Cyan Start-Process powershell.exe -ArgumentList "-ExecutionPolicy Bypass -NoProfile -File `"$scriptPath`" -ConfigPath `"$absConfigPath`"" Write-Host "[$(Get-Timestamp)] New instance started. Scheduling shutdown..." -ForegroundColor Green $script:WaitingForNewInstance = $true $script:UpdateStartTime = Get-Date return @{ content = "Update applied. New instance started, current instance shutting down." structuredContent = @{ success = $true; scriptPath = $scriptPath; updateUrl = $updateUrl; triggerShutdown = $true } } } # -- Tool Registration --------------------------------------------------------- # Explicit catalogue of all functions that tool handlers may call inside a runspace. # Using ${function:Name} captures the scriptblock from the current script scope - # the only reliable way to get script-scope functions without AST parsing tricks. # Add any new helper function here when it is needed by a tool handler. $script:BridgeFunctions = @( @{ Name = 'Get-Timestamp'; Body = ${function:Get-Timestamp}.ToString() } @{ Name = 'Read-ExchangeAddressCache'; Body = ${function:Read-ExchangeAddressCache}.ToString() } @{ Name = 'Write-ExchangeAddressCache'; Body = ${function:Write-ExchangeAddressCache}.ToString() } @{ Name = 'Execute-PingTool'; Body = ${function:Execute-PingTool}.ToString() } @{ Name = 'Execute-PowerShellTool'; Body = ${function:Execute-PowerShellTool}.ToString() } @{ Name = 'Get-CalendarItems'; Body = ${function:Get-CalendarItems}.ToString() } @{ Name = 'Execute-GetCalendarItemsTool'; Body = ${function:Execute-GetCalendarItemsTool}.ToString() } @{ Name = 'Add-CalendarEvent'; Body = ${function:Add-CalendarEvent}.ToString() } @{ Name = 'Update-CalendarEvent'; Body = ${function:Update-CalendarEvent}.ToString() } @{ Name = 'Remove-CalendarEvent'; Body = ${function:Remove-CalendarEvent}.ToString() } @{ Name = 'Remove-OldEBusyEvents'; Body = ${function:Remove-OldEBusyEvents}.ToString() } @{ Name = 'Get-MailList'; Body = ${function:Get-MailList}.ToString() } @{ Name = 'Find-MailByEntryId'; Body = ${function:Find-MailByEntryId}.ToString() } @{ Name = 'Get-MailContent'; Body = ${function:Get-MailContent}.ToString() } @{ Name = 'Remove-Mail'; Body = ${function:Remove-Mail}.ToString() } @{ Name = 'Cleanup-Mail'; Body = ${function:Cleanup-Mail}.ToString() } @{ Name = 'Get-MailFolders'; Body = ${function:Get-MailFolders}.ToString() } @{ Name = 'Move-MailToFolder'; Body = ${function:Move-MailToFolder}.ToString() } @{ Name = 'Search-Mails'; Body = ${function:Search-Mails}.ToString() } @{ Name = 'Set-MailReadState'; Body = ${function:Set-MailReadState}.ToString() } @{ Name = 'Get-MailAttachmentsList'; Body = ${function:Get-MailAttachmentsList}.ToString() } @{ Name = 'Save-MailAttachment'; Body = ${function:Save-MailAttachment}.ToString() } @{ Name = 'Get-MailsBySubject'; Body = ${function:Get-MailsBySubject}.ToString() } @{ Name = 'Get-MailItems'; Body = ${function:Get-MailItems}.ToString() } @{ Name = 'Read-RuleConditions'; Body = ${function:Read-RuleConditions}.ToString() } @{ Name = 'Read-RuleActions'; Body = ${function:Read-RuleActions}.ToString() } @{ Name = 'Get-InboxRules'; Body = ${function:Get-InboxRules}.ToString() } @{ Name = 'Get-RuleByName'; Body = ${function:Get-RuleByName}.ToString() } @{ Name = 'Execute-SelfUpdateTool'; Body = ${function:Execute-SelfUpdateTool}.ToString() } ) Write-Host "[$(Get-Timestamp)] Genaia PowerShell Bridge $script:Version" -ForegroundColor Magenta Register-Tool -Definition @{ name = "ping" title = "Ping" category = "Bridge" description = "A simple ping tool that returns pong with the current timestamp" inputSchema = @{ type = "object"; properties = @{}; required = @() } } -Handler { param($Parameters) Execute-PingTool -Parameters $Parameters } -TimeoutSeconds 10 Register-Tool -Definition @{ name = "powershell" title = "PowerShell" category = "OS Tools" description = "Execute PowerShell scripts and return stdout/stderr output" inputSchema = @{ type = "object" properties = @{ script = @{ type = "string"; description = "PowerShell script to execute" } } required = @("script") } } -Handler { param($Parameters) Execute-PowerShellTool -Parameters $Parameters } -TimeoutSeconds 120 Register-Tool -Definition @{ name = "calendar_diagnose" title = "Diagnose calendar" category = "Calendar" description = "Diagnose Outlook calendar performance - measures time for each step" inputSchema = @{ type = "object"; properties = @{}; required = @() } } -Handler { param($Parameters) $results = @() $sw = [Diagnostics.Stopwatch]::StartNew() try { $o = New-Object -ComObject Outlook.Application $sw.Stop(); $results += "COM init: $($sw.ElapsedMilliseconds)ms" $sw.Restart(); $c = $o.Session.GetDefaultFolder(9); $sw.Stop() $results += "GetDefaultFolder: $($sw.ElapsedMilliseconds)ms" $sw.Restart(); $items = $c.Items; $items.Sort("[Start]"); $items.IncludeRecurrences = $true; $sw.Stop() $results += "Items setup: $($sw.ElapsedMilliseconds)ms" $s = (Get-Date).AddDays(-1); $e = (Get-Date).AddDays(3) $f = "[Start] >= '" + $s.ToString("g") + "' AND [End] <= '" + $e.ToString("g") + "'" $results += "Filter: $f" $sw.Restart(); $r = $items.Restrict($f); $sw.Stop() $results += "Restrict: $($sw.ElapsedMilliseconds)ms" $sw.Restart(); $n = 0; foreach ($i in $r) { $n++ }; $sw.Stop() $results += "Iterate $n items (no body): $($sw.ElapsedMilliseconds)ms" $sw.Restart(); $n = 0; foreach ($i in $r) { $null = $i.Body; $n++ }; $sw.Stop() $results += "Iterate $n items (with Body): $($sw.ElapsedMilliseconds)ms" $sw.Restart(); $n = 0; foreach ($i in $r) { $null = $i.HTMLBody; $n++ }; $sw.Stop() $results += "Iterate $n items (with HTMLBody): $($sw.ElapsedMilliseconds)ms" $sw.Restart(); $n = 0; foreach ($i in $r) { $null = $i.Recipients.Count; $n++ }; $sw.Stop() $results += "Iterate $n items (with Recipients.Count): $($sw.ElapsedMilliseconds)ms" } catch { $results += "ERROR: $_" } $output = $results -join "`n" # @(...) forces an array even when PowerShell unwrapped a single-item result to a scalar return @{ content = $output; structuredContent = @{ results = @($results) } } } -TimeoutSeconds 120 Register-Tool -Definition @{ name = "calendar_get_items" title = "View calendar" category = "Calendar" description = "Get calendar events from Outlook" inputSchema = @{ type = "object" properties = @{ startDays = @{ type = "integer"; description = "Days from today to start search (negative for past, default: -7)" } endDays = @{ type = "integer"; description = "Days from today to end search (positive for future, default: 7)" } onlySubjects = @{ type = "array"; items = @{ type = "string" }; description = "Only include events with subjects matching these patterns" } excludeSubjects = @{ type = "array"; items = @{ type = "string" }; description = "Exclude events with subjects matching these patterns" } } required = @() } } -Handler { param($Parameters) Execute-GetCalendarItemsTool -Parameters $Parameters } -TimeoutSeconds 90 Register-Tool -Definition @{ name = "calendar_add_event" title = "Create event" category = "Calendar" description = "Add a new calendar event to Outlook" inputSchema = @{ type = "object" properties = @{ subject = @{ type = "string"; description = "Event subject/title" } start = @{ type = "string"; description = "Start date/time in ISO format" } end = @{ type = "string"; description = "End date/time in ISO format" } body = @{ type = "string"; description = "Event body/description" } allDay = @{ type = "boolean"; description = "Whether this is an all-day event" } busyStatus = @{ type = "integer"; description = "Busy status: 0=Free, 1=Tentative, 2=Busy, 3=OutOfOffice (default: 2)" } location = @{ type = "string"; description = "Event location or meeting URL" } reminderMinutes = @{ type = "integer"; description = "Reminder lead time in minutes before start. 0 disables the reminder entirely. Omit to use Outlook's own default reminder." } } required = @("subject", "start", "end") } } -Handler { param($Parameters) try { $body = if ($Parameters.body) { $Parameters.body } else { "" } $allDay = if ($Parameters.allDay) { $Parameters.allDay } else { $false } $busyStatus = if ($null -ne $Parameters.busyStatus) { $Parameters.busyStatus } else { 2 } $location = if ($Parameters.location) { $Parameters.location } else { "" } $reminderMinutes = if ($null -ne $Parameters.reminderMinutes) { [int]$Parameters.reminderMinutes } else { -1 } $startDate = $null; $endDate = $null try { $startDate = [DateTime]::Parse($Parameters.start) } catch { $startDate = [DateTime]::ParseExact($Parameters.start, "yyyy-MM-ddTHH:mm:ss.fffZ", $null) } try { $endDate = [DateTime]::Parse($Parameters.end) } catch { $endDate = [DateTime]::ParseExact($Parameters.end, "yyyy-MM-ddTHH:mm:ss.fffZ", $null) } $entryId = Add-CalendarEvent -Subject $Parameters.subject -Start $startDate -End $endDate -Body $body -AllDay $allDay -BusyStatus $busyStatus -Location $location -ReminderMinutes $reminderMinutes return @{ content = "Calendar event created successfully"; structuredContent = @{ entryId = $entryId } } } catch { return @{ content = "Failed to create calendar event: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "calendar_update_event" title = "Update event" category = "Calendar" description = "Update an existing Outlook calendar event by EntryId" inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId of the event" } subject = @{ type = "string"; description = "New subject" } start = @{ type = "string"; description = "New start date/time in ISO format" } end = @{ type = "string"; description = "New end date/time in ISO format" } body = @{ type = "string"; description = "New body" } allDay = @{ type = "boolean"; description = "Change all-day flag" } busyStatus = @{ type = "integer"; description = "New busy status" } location = @{ type = "string"; description = "New location" } } required = @("entryId") } } -Handler { param($Parameters) try { $startDate = if ($Parameters.start) { [DateTime]::Parse($Parameters.start) } else { $null } $endDate = if ($Parameters.end) { [DateTime]::Parse($Parameters.end) } else { $null } $result = Update-CalendarEvent ` -EntryId $Parameters.entryId ` -Subject $Parameters.subject ` -Start $startDate ` -End $endDate ` -Body $Parameters.body ` -AllDay $Parameters.allDay ` -BusyStatus $Parameters.busyStatus ` -Location $Parameters.location return @{ content = if ($result) { "Calendar event updated" } else { "Update returned false" }; structuredContent = @{ success = $result } } } catch { return @{ content = "Failed to update: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "calendar_delete_event" title = "Delete event" category = "Calendar" description = "Delete an Outlook calendar event by EntryId" inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId" } } required = @("entryId") } } -Handler { param($Parameters) try { $result = Remove-CalendarEvent -EntryId $Parameters.entryId return @{ content = if ($result) { "Event deleted" } else { "Delete returned false" }; structuredContent = @{ success = $result } } } catch { return @{ content = "Failed to delete: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_list" title = "List emails" category = "Mail" description = "List emails from Outlook inbox" inputSchema = @{ type = "object" properties = @{ count = @{ type = "integer"; description = "Max number of emails (default: 50)" } onlySubjects = @{ type = "array"; items = @{ type = "string" }; description = "Filter by subject patterns" } excludeSubjects = @{ type = "array"; items = @{ type = "string" }; description = "Exclude by subject patterns" } onlySenders = @{ type = "array"; items = @{ type = "string" }; description = "Filter by sender name/address" } excludeSenders = @{ type = "array"; items = @{ type = "string" }; description = "Exclude by sender name/address" } startDate = @{ type = "string"; description = "ISO start date (default: 7 days ago)" } endDate = @{ type = "string"; description = "ISO end date (default: now)" } } required = @() } } -Handler { param($Parameters) try { $count = if ($Parameters.count) { [int]$Parameters.count } else { 50 } $onlySubjects = if ($Parameters.onlySubjects) { $Parameters.onlySubjects } else { @() } $excludeSubjects = if ($Parameters.excludeSubjects) { $Parameters.excludeSubjects } else { @() } $onlySenders = if ($Parameters.onlySenders) { $Parameters.onlySenders } else { @() } $excludeSenders = if ($Parameters.excludeSenders) { $Parameters.excludeSenders } else { @() } $startDate = if ($Parameters.startDate) { [DateTime]::Parse($Parameters.startDate) } else { (Get-Date).AddDays(-7).Date } $endDate = if ($Parameters.endDate) { [DateTime]::Parse($Parameters.endDate) } else { (Get-Date).Date.AddDays(1).AddSeconds(-1) } $mails = Get-MailList -Count $count -OnlySubjects $onlySubjects -ExcludeSubjects $excludeSubjects -OnlySenders $onlySenders -ExcludeSenders $excludeSenders -StartDate $startDate -EndDate $endDate if (-not $mails) { $mails = @() } $content = if ($mails.Count -gt 0) { "Found $($mails.Count) emails:`n" + ($mails | ForEach-Object { "$($_.ReceivedTime.ToString('yyyy-MM-dd HH:mm')) | $($_.SenderName) | $($_.Subject) | entryId=$($_.EntryId) storeId=$($_.StoreId)" } | Out-String) } else { "No emails found matching the criteria." } # @(...) forces an array even when PowerShell unwrapped a single-item result to a scalar return @{ content = $content; structuredContent = @{ mails = @($mails); count = $mails.Count } } } catch { return @{ content = "Mail list failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_get_items" title = "Get mail items (sync)" category = "Mail" description = "Fetch emails from Outlook Inbox with RFC822 headers (Message-ID, In-Reply-To, References, ConversationID) and pagination. Designed for Temporal mail sync. Uses Restrict filter for efficient date-range queries." inputSchema = @{ type = "object" properties = @{ startDate = @{ type = "string"; description = "ISO start date for ReceivedTime filter (default: 7 days ago)" } endDate = @{ type = "string"; description = "ISO end date for ReceivedTime filter (default: tomorrow)" } excludeSubjects = @{ type = "array"; items = @{ type = "string" }; description = "Exclude emails whose Subject matches any of these regex patterns" } excludeSenders = @{ type = "array"; items = @{ type = "string" }; description = "Exclude emails whose SenderName or SenderEmailAddress matches any of these regex patterns" } fetchBody = @{ type = "boolean"; description = "Whether to include Body and HTMLBody in response (default: false). Skipped for encrypted messages." } limit = @{ type = "integer"; description = "Max number of emails to return (default: 50, 0 = no limit)" } offset = @{ type = "integer"; description = "Number of matching emails to skip for pagination (default: 0)" } } required = @() } } -Handler { param($Parameters) try { $startDate = if ($Parameters.startDate) { [DateTime]::Parse($Parameters.startDate) } else { (Get-Date).AddDays(-7) } $endDate = if ($Parameters.endDate) { [DateTime]::Parse($Parameters.endDate) } else { (Get-Date).AddDays(1) } $excludeSubjects = if ($Parameters.excludeSubjects) { $Parameters.excludeSubjects } else { @() } $excludeSenders = if ($Parameters.excludeSenders) { $Parameters.excludeSenders } else { @() } $fetchBody = if ($null -ne $Parameters.fetchBody) { [bool]$Parameters.fetchBody } else { $false } $limit = if ($Parameters.limit) { [int]$Parameters.limit } else { 50 } $offset = if ($Parameters.offset) { [int]$Parameters.offset } else { 0 } $mails = Get-MailItems -StartDate $startDate -EndDate $endDate -ExcludeSubjects $excludeSubjects -ExcludeSenders $excludeSenders -FetchBody $fetchBody -Limit $limit -Offset $offset if (-not $mails) { $mails = @() } $content = if ($mails.Count -gt 0) { "Found $($mails.Count) emails:`n" + ($mails | ForEach-Object { "$($_.ReceivedTime) | $($_.SenderName) | $($_.Subject) | msgId=$($_.MessageId) convId=$($_.ConversationID)" } | Out-String) } else { "No emails found in the specified time range." } return @{ content = $content structuredContent = @{ mails = @($mails) count = $mails.Count offset = $offset limit = $limit } } } catch { return @{ content = "mail_get_items failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_get_content" title = "Read email" category = "Mail" description = "Get full content of an email by EntryId" inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId (from mail_list)" } storeId = @{ type = "string"; description = "Outlook StoreId (from mail_list, required for Exchange)" } } required = @("entryId") } } -Handler { param($Parameters) try { $mail = Get-MailContent -EntryId $Parameters.entryId -StoreId $Parameters.storeId if (-not $mail) { return @{ content = "Email not found"; structuredContent = @{ error = "not found" } } } return @{ content = $mail.Body; structuredContent = $mail } } catch { return @{ content = "Failed to get mail: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_delete" title = "Delete email" category = "Mail" description = "Delete an email by EntryId" inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId (from mail_list)" } storeId = @{ type = "string"; description = "Outlook StoreId (from mail_list, required for Exchange)" } } required = @("entryId") } } -Handler { param($Parameters) try { $result = Remove-Mail -EntryId $Parameters.entryId -StoreId $Parameters.storeId return @{ content = if ($result) { "Email deleted" } else { "Delete returned false" }; structuredContent = @{ success = $result } } } catch { return @{ content = "Failed to delete: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_list_folders" title = "List mail folders" category = "Mail" description = "List all Outlook mail folders with their paths. Use folder path with mail_move to move emails." inputSchema = @{ type = "object" properties = @{} required = @() } } -Handler { param($Parameters) try { $folders = Get-MailFolders if (-not $folders) { $folders = @() } $content = if ($folders.Count -gt 0) { "Found $($folders.Count) folders:`n" + ($folders | ForEach-Object { $_.Path } | Out-String) } else { "No folders found." } # @(...) forces an array even when PowerShell unwrapped a single-item result to a scalar return @{ content = $content; structuredContent = @{ folders = @($folders); count = $folders.Count } } } catch { return @{ content = "Failed to list folders: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_move" title = "Move email" category = "Mail" description = "Move an email to a specified folder. Use mail_list_folders to get folder paths." inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId of the email (from mail_list)" } folderPath = @{ type = "string"; description = "Destination folder path (e.g. 'Inbox\Archive' or 'Deleted Items')" } } required = @("entryId", "folderPath") } } -Handler { param($Parameters) try { Move-MailToFolder -EntryId $Parameters.entryId -FolderPath $Parameters.folderPath return @{ content = "Email moved to $($Parameters.folderPath)"; structuredContent = @{ success = $true; folderPath = $Parameters.folderPath } } } catch { return @{ content = "Failed to move: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_search" title = "Search emails" category = "Mail" description = "Search emails by keyword in subject, body, or sender. Returns matching emails with entryId." inputSchema = @{ type = "object" properties = @{ query = @{ type = "string"; description = "Keyword to search for" } folder = @{ type = "string"; description = "Folder to search in (default: Inbox). Use mail_list_folders to get paths." } count = @{ type = "integer"; description = "Max results (default: 25)" } fields = @{ type = "array"; items = @{ type = "string" }; description = "Fields to search: Subject, Body, SenderName, SenderEmailAddress (default: all)" } } required = @("query") } } -Handler { param($Parameters) try { $folder = if ($Parameters.folder) { $Parameters.folder } else { "Inbox" } $count = if ($Parameters.count) { [int]$Parameters.count } else { 25 } $fields = if ($Parameters.fields -and $Parameters.fields.Count -gt 0) { $Parameters.fields } else { @("Subject", "Body", "SenderName", "SenderEmailAddress") } $mails = Search-Mails -Query $Parameters.query -FolderPath $folder -Count $count -Fields $fields if (-not $mails) { $mails = @() } $content = if ($mails.Count -gt 0) { "Found $($mails.Count) emails matching '$($Parameters.query)':`n" + ($mails | ForEach-Object { "$($_.ReceivedTime.ToString('yyyy-MM-dd HH:mm')) | $($_.SenderName) | $($_.Subject) | entryId=$($_.EntryId) storeId=$($_.StoreId)" } | Out-String) } else { "No emails found matching '$($Parameters.query)'." } # @(...) forces an array even when PowerShell unwrapped a single-item result to a scalar return @{ content = $content; structuredContent = @{ mails = @($mails); count = $mails.Count } } } catch { return @{ content = "Search failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_mark_read" title = "Mark email as read" category = "Mail" description = "Mark an email as read by EntryId" inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId (from mail_list or mail_search)" } } required = @("entryId") } } -Handler { param($Parameters) try { Set-MailReadState -EntryId $Parameters.entryId -MarkAsRead $true return @{ content = "Email marked as read"; structuredContent = @{ success = $true } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_mark_unread" title = "Mark email as unread" category = "Mail" description = "Mark an email as unread by EntryId" inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId (from mail_list or mail_search)" } } required = @("entryId") } } -Handler { param($Parameters) try { Set-MailReadState -EntryId $Parameters.entryId -MarkAsRead $false return @{ content = "Email marked as unread"; structuredContent = @{ success = $true } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "mail_get_attachments" title = "List/save attachments" category = "Mail" description = "List attachments of an email. Optionally save one to disk by specifying its index." inputSchema = @{ type = "object" properties = @{ entryId = @{ type = "string"; description = "Outlook EntryId (from mail_list or mail_search)" } index = @{ type = "integer"; description = "Attachment index (1-based) to save to disk. Omit to only list attachments." } savePath = @{ type = "string"; description = "Path to save the attachment (default: %TEMP%\\filename)" } } required = @("entryId") } } -Handler { param($Parameters) try { $attachments = Get-MailAttachmentsList -EntryId $Parameters.entryId if (-not $attachments) { $attachments = @() } if ($Parameters.index) { $savedPath = Save-MailAttachment -EntryId $Parameters.entryId -Index ([int]$Parameters.index) -SavePath $Parameters.savePath $att = $attachments | Where-Object { $_.Index -eq [int]$Parameters.index } | Select-Object -First 1 return @{ content = "Saved '$($att.FileName)' to $savedPath"; structuredContent = @{ savedPath = $savedPath; fileName = $att.FileName } } } $content = if ($attachments.Count -gt 0) { "Found $($attachments.Count) attachment(s):`n" + ($attachments | ForEach-Object { "[$($_.Index)] $($_.FileName) ($([math]::Round($_.Size/1024,1)) KB)" } | Out-String) } else { "No attachments." } return @{ content = $content; structuredContent = @{ attachments = $attachments; count = $attachments.Count } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "get_inbox_rules" title = "List rules" category = "Rules" description = "List all Outlook inbox rules" inputSchema = @{ type = "object"; properties = @{}; required = @() } } -Handler { param($Parameters) try { $rules = Get-InboxRules $count = 0; try { $count = $rules.Count } catch {} $result = @() for ($i = 1; $i -le $count; $i++) { try { $r = $rules.Item($i) $result += @{ name = $r.Name; enabled = $r.Enabled; conditions = (Read-RuleConditions -r $r); actions = (Read-RuleActions -r $r) } } catch { continue } } # @(...) forces an array even when PowerShell unwrapped a single-item result to a scalar return @{ content = "Found $($result.Count) inbox rules"; structuredContent = @{ rules = @($result); count = $result.Count } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "update_rule" title = "Update rule" category = "Rules" description = "Update an existing Outlook inbox rule (enable/disable or rename)" inputSchema = @{ type = "object" properties = @{ name = @{ type = "string"; description = "Current rule name" } enabled = @{ type = "boolean"; description = "Enable or disable the rule" } newName = @{ type = "string"; description = "New name for the rule" } } required = @("name") } } -Handler { param($Parameters) try { $found = Get-RuleByName -Name $Parameters.name if (-not $found) { throw "Rule not found: $($Parameters.name)" } $r = $found.rule; $rules = $found.rules if ($null -ne $Parameters.enabled) { $r.Enabled = $Parameters.enabled } if ($Parameters.newName) { $r.Name = $Parameters.newName } $rules.Save() return @{ content = "Rule updated: $($Parameters.name)"; structuredContent = @{ name = $Parameters.name; updated = $true } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "delete_rule" title = "Delete rule" category = "Rules" description = "Delete an Outlook inbox rule by name" inputSchema = @{ type = "object" properties = @{ name = @{ type = "string"; description = "Rule name to delete" } } required = @("name") } } -Handler { param($Parameters) try { $found = Get-RuleByName -Name $Parameters.name if (-not $found) { throw "Rule not found: $($Parameters.name)" } $found.rules.Remove($Parameters.name) $found.rules.Save() return @{ content = "Rule deleted: $($Parameters.name)"; structuredContent = @{ name = $Parameters.name; deleted = $true } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "execute_rule" title = "Execute rule" category = "Rules" description = "Manually run an Outlook rule against existing messages (for testing or catch-up)" inputSchema = @{ type = "object" properties = @{ name = @{ type = "string"; description = "Rule name to execute" } folder = @{ type = "string"; description = "Folder to run against (default: Inbox)" } includeSubfolders = @{ type = "boolean"; description = "Include subfolders (default: false)" } } required = @("name") } } -Handler { param($Parameters) try { $found = Get-RuleByName -Name $Parameters.name if (-not $found) { throw "Rule not found: $($Parameters.name)" } $r = $found.rule $outlook = New-Object -ComObject Outlook.Application $targetFolder = if ($Parameters.folder) { $outlook.Session.GetDefaultFolder(6).Folders | Where-Object { $_.Name -eq $Parameters.folder } | Select-Object -First 1 } else { $outlook.Session.GetDefaultFolder(6) } $includeSubfolders = if ($Parameters.includeSubfolders) { $Parameters.includeSubfolders } else { $false } $r.Execute($true, $targetFolder, $includeSubfolders) return @{ content = "Rule executed: $($Parameters.name)"; structuredContent = @{ name = $Parameters.name; executed = $true } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "create_rule" title = "Create rule" category = "Rules" description = "Create a new Outlook inbox rule with conditions and actions (move, copy, forward, redirect, mark as read, delete). Optionally execute it immediately against existing messages in a folder after creation." inputSchema = @{ type = "object" properties = @{ name = @{ type = "string"; description = "Rule name (must be unique)" } enabled = @{ type = "boolean"; description = "Whether the rule is active (default: true)" } # Conditions fromAddresses = @{ type = "array"; items = @{ type = "string" }; description = "Trigger when sender is one of these addresses" } subjectContains = @{ type = "array"; items = @{ type = "string" }; description = "Trigger when subject contains any of these strings" } bodyContains = @{ type = "array"; items = @{ type = "string" }; description = "Trigger when body contains any of these strings" } bodyOrSubjectContains = @{ type = "array"; items = @{ type = "string" }; description = "Trigger when body or subject contains any of these strings" } hasAttachment = @{ type = "boolean"; description = "Trigger when message has an attachment" } toMe = @{ type = "boolean"; description = "Trigger when message is sent directly to me" } # Actions moveToFolder = @{ type = "string"; description = "Move matched messages to this folder name (must exist)" } copyToFolder = @{ type = "string"; description = "Copy matched messages to this folder name (must exist)" } markAsRead = @{ type = "boolean"; description = "Mark matched messages as read" } deleteMessage = @{ type = "boolean"; description = "Delete matched messages" } forwardTo = @{ type = "array"; items = @{ type = "string" }; description = "Forward matched messages to these email addresses" } redirectTo = @{ type = "array"; items = @{ type = "string" }; description = "Redirect (forward without keeping original sender) matched messages to these email addresses" } # Execute after create executeAfterCreate = @{ type = "boolean"; description = "Run the rule immediately against existing messages after creating it (default: false)" } executeFolder = @{ type = "string"; description = "Folder to run the rule against when executeAfterCreate=true (default: Inbox)" } executeIncludeSubfolders = @{ type = "boolean"; description = "Include subfolders when running rule after creation (default: false)" } } required = @("name") } } -Handler { param($Parameters) try { $outlook = New-Object -ComObject Outlook.Application $rules = $outlook.Session.DefaultStore.GetRules() # Verify name is unique $count = 0; try { $count = $rules.Count } catch {} for ($i = 1; $i -le $count; $i++) { try { if ($rules.Item($i).Name -eq $Parameters.name) { throw "Rule already exists: $($Parameters.name)" } } catch [System.Runtime.InteropServices.COMException] { continue } } $rule = $rules.Create($Parameters.name, 0) # 0 = olRuleReceive # -- Conditions ------------------------------------------------------- if ($Parameters.fromAddresses -and $Parameters.fromAddresses.Count -gt 0) { foreach ($addr in $Parameters.fromAddresses) { $recip = $outlook.Session.CreateRecipient($addr) $recip.Resolve() | Out-Null $rule.Conditions.From.Recipients.Add($recip) | Out-Null } $rule.Conditions.From.Enabled = $true } if ($Parameters.subjectContains -and $Parameters.subjectContains.Count -gt 0) { $rule.Conditions.Subject.Text = $Parameters.subjectContains $rule.Conditions.Subject.Enabled = $true } if ($Parameters.bodyContains -and $Parameters.bodyContains.Count -gt 0) { $rule.Conditions.Body.Text = $Parameters.bodyContains $rule.Conditions.Body.Enabled = $true } if ($Parameters.bodyOrSubjectContains -and $Parameters.bodyOrSubjectContains.Count -gt 0) { $rule.Conditions.BodyOrSubject.Text = $Parameters.bodyOrSubjectContains $rule.Conditions.BodyOrSubject.Enabled = $true } if ($Parameters.hasAttachment -eq $true) { $rule.Conditions.HasAttachment.Enabled = $true } if ($Parameters.toMe -eq $true) { $rule.Conditions.ToMe.Enabled = $true } # -- Actions ---------------------------------------------------------- if ($Parameters.moveToFolder) { $inbox = $outlook.Session.GetDefaultFolder(6) $folder = $inbox.Folders | Where-Object { $_.Name -eq $Parameters.moveToFolder } | Select-Object -First 1 if (-not $folder) { throw "Folder not found: $($Parameters.moveToFolder)" } $rule.Actions.MoveToFolder.Folder = $folder $rule.Actions.MoveToFolder.Enabled = $true } if ($Parameters.copyToFolder) { $inbox = $outlook.Session.GetDefaultFolder(6) $folder = $inbox.Folders | Where-Object { $_.Name -eq $Parameters.copyToFolder } | Select-Object -First 1 if (-not $folder) { throw "Folder not found: $($Parameters.copyToFolder)" } $rule.Actions.CopyToFolder.Folder = $folder $rule.Actions.CopyToFolder.Enabled = $true } if ($Parameters.markAsRead -eq $true) { $rule.Actions.MarkAsRead.Enabled = $true } if ($Parameters.deleteMessage -eq $true) { $rule.Actions.Delete.Enabled = $true } if ($Parameters.forwardTo -and $Parameters.forwardTo.Count -gt 0) { foreach ($addr in $Parameters.forwardTo) { $recip = $outlook.Session.CreateRecipient($addr) $recip.Resolve() | Out-Null $rule.Actions.Forward.Recipients.Add($recip) | Out-Null } $rule.Actions.Forward.Enabled = $true } if ($Parameters.redirectTo -and $Parameters.redirectTo.Count -gt 0) { foreach ($addr in $Parameters.redirectTo) { $recip = $outlook.Session.CreateRecipient($addr) $recip.Resolve() | Out-Null $rule.Actions.Redirect.Recipients.Add($recip) | Out-Null } $rule.Actions.Redirect.Enabled = $true } # -- Enabled state ---------------------------------------------------- if ($null -ne $Parameters.enabled) { $rule.Enabled = [bool]$Parameters.enabled } else { $rule.Enabled = $true } $rules.Save() $executeResult = $null if ($Parameters.executeAfterCreate -eq $true) { $targetFolder = if ($Parameters.executeFolder) { $outlook.Session.GetDefaultFolder(6).Folders | Where-Object { $_.Name -eq $Parameters.executeFolder } | Select-Object -First 1 } else { $outlook.Session.GetDefaultFolder(6) } $includeSubfolders = if ($Parameters.executeIncludeSubfolders) { $Parameters.executeIncludeSubfolders } else { $false } $rule.Execute($true, $targetFolder, $includeSubfolders) $executeResult = @{ executed = $true; folder = if ($Parameters.executeFolder) { $Parameters.executeFolder } else { "Inbox" } } } $msg = "Rule created: $($Parameters.name)" if ($executeResult) { $msg += " (executed against $($executeResult.folder))" } return @{ content = $msg structuredContent = @{ name = $Parameters.name created = $true executeResult = $executeResult } } } catch { return @{ content = "Failed: $($_.Exception.Message)"; structuredContent = @{ error = $_.Exception.Message } } } } Register-Tool -Definition @{ name = "update_bridge" title = "Update bridge" category = "Bridge" description = "Download and apply a new version of the bridge script from the configured updateUrl, then restart" inputSchema = @{ type = "object"; properties = @{}; required = @() } } -Handler { param($Parameters) Execute-SelfUpdateTool -Parameters $Parameters } Register-Tool -Definition @{ name = "read" title = "Read" category = "OS Tools" description = "Read file content (optionally with line range) or list directory contents - auto-detected by path type" inputSchema = @{ type = "object" properties = @{ path = @{ type = "string"; description = "File or directory path" } startLine = @{ type = "number"; description = "Start line number (1-based, files only)" } endLine = @{ type = "number"; description = "End line number (1-based, files only)" } depth = @{ type = "number"; description = "Directory listing depth (0 = flat, default: 0)" } } required = @("path") } } -Handler { param($Parameters) $p = $Parameters.path if ($p -match "^~") { $p = $p -replace "^~", $env:USERPROFILE } if (Test-Path -Path $p -PathType Container) { # Directory listing $depth = if ($null -ne $Parameters.depth) { [int]$Parameters.depth } else { 0 } function Get-DirEntries($dir, $currentDepth, $maxDepth) { $entries = Get-ChildItem -Path $dir -Force $result = @() foreach ($e in $entries) { $type = if ($e.PSIsContainer) { "d" } else { "f" } $result += "$type $($e.FullName)" if ($e.PSIsContainer -and $currentDepth -lt $maxDepth) { $result += Get-DirEntries $e.FullName ($currentDepth + 1) $maxDepth } } return $result } $lines = Get-DirEntries $p 0 $depth $content = $lines -join "`n" return @{ content = $content; structuredContent = @{ path = $p; type = "directory" } } } else { # File read $content = [System.IO.File]::ReadAllText($p, [System.Text.Encoding]::UTF8) if ($null -ne $Parameters.startLine -or $null -ne $Parameters.endLine) { $lines = $content -split "`n" $start = if ($null -ne $Parameters.startLine) { [int]$Parameters.startLine - 1 } else { 0 } $end = if ($null -ne $Parameters.endLine) { [int]$Parameters.endLine } else { $lines.Length } $content = ($lines[$start..($end - 1)]) -join "`n" return @{ content = $content; structuredContent = @{ path = $p; startLine = $Parameters.startLine; endLine = $Parameters.endLine; totalLines = $lines.Length } } } return @{ content = $content; structuredContent = @{ path = $p; size = $content.Length } } } } -TimeoutSeconds 30 Register-Tool -Definition @{ name = "write" title = "Write file" category = "OS Tools" description = "Create or edit a file. Use command='create' to write full content, 'strReplace' to replace a specific string, 'insert' to insert at a line." inputSchema = @{ type = "object" properties = @{ command = @{ type = "string"; enum = @("create", "strReplace", "insert"); description = "Operation: 'create' writes full file, 'strReplace' replaces oldStr with newStr, 'insert' inserts content at insertLine (or appends if omitted)" } path = @{ type = "string"; description = "File path" } content = @{ type = "string"; description = "Content to write (required for create and insert)" } oldStr = @{ type = "string"; description = "String to replace (required for strReplace)" } newStr = @{ type = "string"; description = "Replacement string (required for strReplace)" } insertLine = @{ type = "number"; description = "0-based line index to insert at (insert mode; omit to append)" } replaceAll = @{ type = "boolean"; description = "Replace all occurrences (strReplace mode, default: false)" } } required = @("command", "path") } } -Handler { param($Parameters) $p = $Parameters.path if ($p -match "^~") { $p = $p -replace "^~", $env:USERPROFILE } $cmd = $Parameters.command if ($cmd -eq "create") { if ($null -eq $Parameters.content) { throw "'content' required for create" } $dir = Split-Path -Parent $p if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } [System.IO.File]::WriteAllText($p, $Parameters.content, [System.Text.Encoding]::UTF8) return @{ content = "File written: $p"; structuredContent = @{ path = $p; size = $Parameters.content.Length } } } elseif ($cmd -eq "strReplace") { if ($null -eq $Parameters.oldStr -or $null -eq $Parameters.newStr) { throw "'oldStr' and 'newStr' required for strReplace" } $existing = [System.IO.File]::ReadAllText($p, [System.Text.Encoding]::UTF8) if (-not $existing.Contains($Parameters.oldStr)) { throw "oldStr not found in $p" } $replaceAll = if ($null -ne $Parameters.replaceAll) { [bool]$Parameters.replaceAll } else { $false } $updated = if ($replaceAll) { $existing.Replace($Parameters.oldStr, $Parameters.newStr) } else { $existing.Replace($Parameters.oldStr, $Parameters.newStr) } # For single replace: use regex with count limit via workaround if (-not $replaceAll) { $idx = $existing.IndexOf($Parameters.oldStr) $updated = $existing.Substring(0, $idx) + $Parameters.newStr + $existing.Substring($idx + $Parameters.oldStr.Length) } [System.IO.File]::WriteAllText($p, $updated, [System.Text.Encoding]::UTF8) return @{ content = "File updated: $p"; structuredContent = @{ path = $p; oldStr = $Parameters.oldStr; newStr = $Parameters.newStr } } } elseif ($cmd -eq "insert") { if ($null -eq $Parameters.content) { throw "'content' required for insert" } $existing = "" if (Test-Path $p) { $existing = [System.IO.File]::ReadAllText($p, [System.Text.Encoding]::UTF8) } if ($null -eq $Parameters.insertLine) { $updated = if ($existing) { if ($existing.EndsWith("`n")) { $existing + $Parameters.content } else { $existing + "`n" + $Parameters.content } } else { $Parameters.content } } else { $lines = if ($existing) { $existing -split "`n" } else { @() } $insertAt = [int]$Parameters.insertLine $linesList = [System.Collections.Generic.List[string]]$lines $linesList.Insert($insertAt, $Parameters.content) $updated = $linesList -join "`n" } [System.IO.File]::WriteAllText($p, $updated, [System.Text.Encoding]::UTF8) return @{ content = "Content inserted into $p"; structuredContent = @{ path = $p; insertLine = $Parameters.insertLine } } } else { throw "Unknown command: $cmd" } } -TimeoutSeconds 30 Register-Tool -Definition @{ name = "list_directory" title = "List directory" category = "OS Tools" description = "List files and directories in a given path" inputSchema = @{ type = "object" properties = @{ path = @{ type = "string"; description = "Directory path to list" } } required = @("path") } } -Handler { param($Parameters) $p = $Parameters.path if ($p -match "^~") { $p = $p -replace "^~", $env:USERPROFILE } $items = Get-ChildItem -Path $p -Force | ForEach-Object { @{ name = $_.Name; type = if ($_.PSIsContainer) { "directory" } else { "file" }; size = if ($_.PSIsContainer) { 0 } else { $_.Length } } } $lines = $items | ForEach-Object { "$($_.type): $($_.name) ($($_.size) bytes)" } return @{ content = ($lines -join "`n"); structuredContent = @{ path = $p; items = $items } } } -TimeoutSeconds 10 Register-Tool -Definition @{ name = "create_directory" title = "Create directory" category = "OS Tools" description = "Create a directory (with recursive option)" inputSchema = @{ type = "object" properties = @{ path = @{ type = "string"; description = "Directory path to create" } recursive = @{ type = "boolean"; description = "Create parent directories if they don't exist" } } required = @("path") } } -Handler { param($Parameters) $p = $Parameters.path if ($p -match "^~") { $p = $p -replace "^~", $env:USERPROFILE } New-Item -ItemType Directory -Path $p -Force | Out-Null return @{ content = "Directory created: $p"; structuredContent = @{ path = $p } } } -TimeoutSeconds 10 Register-Tool -Definition @{ name = "delete_path" title = "Delete path" category = "OS Tools" description = "Delete a file or directory" inputSchema = @{ type = "object" properties = @{ path = @{ type = "string"; description = "Path to delete" } recursive = @{ type = "boolean"; description = "Delete directories recursively" } } required = @("path") } } -Handler { param($Parameters) $p = $Parameters.path if ($p -match "^~") { $p = $p -replace "^~", $env:USERPROFILE } $recursive = if ($null -ne $Parameters.recursive) { [bool]$Parameters.recursive } else { $false } if ($recursive) { Remove-Item -Path $p -Recurse -Force } else { Remove-Item -Path $p -Force } return @{ content = "Deleted: $p"; structuredContent = @{ path = $p; recursive = $recursive } } } -TimeoutSeconds 30 Register-Tool -Definition @{ name = "glob" title = "Glob" category = "OS Tools" description = "Find files matching a glob pattern (supports * and **)" inputSchema = @{ type = "object" properties = @{ pattern = @{ type = "string"; description = "Glob pattern, e.g. '**/*.ts', 'src/*.json'" } path = @{ type = "string"; description = "Root directory to search from (default: current directory)" } maxDepth = @{ type = "number"; description = "Maximum directory depth (default: 20)" } limit = @{ type = "number"; description = "Maximum results to return (default: 200)" } } required = @("pattern") } } -Handler { param($Parameters) $base = if ($Parameters.path) { $Parameters.path } else { Get-Location } if ($base -match "^~") { $base = $base -replace "^~", $env:USERPROFILE } $limit = if ($null -ne $Parameters.limit) { [int]$Parameters.limit } else { 200 } $maxDepth = if ($null -ne $Parameters.maxDepth) { [int]$Parameters.maxDepth } else { 20 } # Convert glob pattern to PowerShell-compatible: ** -> recurse $pattern = $Parameters.pattern $isRecursive = $pattern -match "\*\*" $filePattern = if ($isRecursive) { ($pattern -split "/")[-1] } else { $pattern } if (-not $filePattern -or $filePattern -eq "**") { $filePattern = "*" } $searchParams = @{ Path = $base; Filter = $filePattern; File = $true; ErrorAction = "SilentlyContinue" } if ($isRecursive) { $searchParams["Recurse"] = $true; $searchParams["Depth"] = $maxDepth } $results = Get-ChildItem @searchParams | Select-Object -First $limit | ForEach-Object { $_.FullName } $truncated = ($results.Count -ge $limit) $output = $results -join "`n" return @{ content = if ($output) { $output } else { "(no matches)" }; structuredContent = @{ matches = $results; total = $results.Count; truncated = $truncated } } } -TimeoutSeconds 30 Register-Tool -Definition @{ name = "grep" title = "Grep" category = "OS Tools" description = "Search for a regex pattern in files" inputSchema = @{ type = "object" properties = @{ pattern = @{ type = "string"; description = "Regex pattern to search for" } path = @{ type = "string"; description = "Directory to search from (default: current directory)" } include = @{ type = "string"; description = "File name glob filter, e.g. '*.ts'" } caseInsensitive = @{ type = "boolean"; description = "Case-insensitive search (default: false)" } maxMatches = @{ type = "number"; description = "Maximum matches to return (default: 200)" } } required = @("pattern") } } -Handler { param($Parameters) $base = if ($Parameters.path) { $Parameters.path } else { Get-Location } if ($base -match "^~") { $base = $base -replace "^~", $env:USERPROFILE } $maxMatches = if ($null -ne $Parameters.maxMatches) { [int]$Parameters.maxMatches } else { 200 } $caseInsensitive = if ($null -ne $Parameters.caseInsensitive) { [bool]$Parameters.caseInsensitive } else { $false } $include = if ($Parameters.include) { $Parameters.include } else { "*" } $opts = if ($caseInsensitive) { "IgnoreCase" } else { "None" } $searchParams = @{ Path = $base; Pattern = $Parameters.pattern; Filter = $include; Recurse = $true; ErrorAction = "SilentlyContinue" } $results = Select-String @searchParams | Select-Object -First $maxMatches $matches = $results | ForEach-Object { @{ file = $_.Path; line = $_.LineNumber; text = $_.Line } } $output = $matches | ForEach-Object { "$($_.file):$($_.line): $($_.text)" } $content = if ($output) { $output -join "`n" } else { "(no matches)" } return @{ content = $content; structuredContent = @{ matches = $matches; truncated = ($matches.Count -ge $maxMatches) } } } -TimeoutSeconds 60 # -- Main ---------------------------------------------------------------------- try { $script:Config = Load-Config -Path $ConfigPath Write-Host "[$(Get-Timestamp)] Configuration loaded" -ForegroundColor Green if (-not $script:Config.bridgeId) { throw "Bridge ID not found in config" } if (-not $script:Config.apiKey) { throw "API key not found in config" } $script:BridgeId = $script:Config.bridgeId Write-Host "[$(Get-Timestamp)] Bridge ID: $($script:BridgeId)" -ForegroundColor Green # Authenticate to get userId (needed for NATS subject and DB registration) if (-not (Authenticate-User -ApiKey $script:Config.apiKey -BridgeIdParam $script:BridgeId)) { throw "Authentication failed" } # Register bridge and tools in database Register-BridgeAndTools # NATS subject - matches NatsRpcTransport in bridge-core (genaia.bridge.{teamId}.{bridgeId}). # Faza 4c: auth callout grants ownBridgeRpc scoped to the bridge item's teamId, not # userId (see realtime-dispatcher/src/auth-callout.ts buildAuthResponse -> ownBridgeRpc). # Falls back to userId only when teamId could not be resolved (Register-BridgeAndTools # failure) - the server will reject SUB in that case too unless resolveApiKey also # failed to find bridgeTeamId, at which point it grants the userId subject as a mirror # fallback. $natsSubject = if ($script:TeamId) { "genaia.bridge.$($script:TeamId).$($script:BridgeId)" } else { "genaia.bridge.$($script:UserId).$($script:BridgeId)" } Write-Host "[$(Get-Timestamp)] === BRIDGE READY ===" -ForegroundColor Green Write-Host "[$(Get-Timestamp)] NATS: $script:NatsUrl" -ForegroundColor Green Write-Host "[$(Get-Timestamp)] Subject: $natsSubject" -ForegroundColor Green $reconnectCount = 0 # NATS reconnect loop - reconnects indefinitely on any error while ($true) { $reconnectCount++ if ($reconnectCount -gt 1) { Write-Host "[$(Get-Timestamp)] === RECONNECT #$reconnectCount ===" -ForegroundColor Cyan } try { $script:NatsReadBuffer = "" $script:NatsSidCounter = 1 $script:NatsSubscriptions = @{} Start-NatsLoop -BridgeSubject $natsSubject # Check if loop exited due to self-update shutdown signal if ($script:WaitingForNewInstance) { Write-Host "[$(Get-Timestamp)] Self-update complete, exiting." -ForegroundColor Green break } Write-Host "[$(Get-Timestamp)] NATS loop exited cleanly, reconnecting..." -ForegroundColor Yellow } catch { if ($script:WaitingForNewInstance) { Write-Host "[$(Get-Timestamp)] Self-update complete (after NATS close), exiting." -ForegroundColor Green break } Write-Host "[$(Get-Timestamp)] NATS error: $_ - reconnecting in 5s..." -ForegroundColor Yellow } if ($script:WaitingForNewInstance) { Write-Host "[$(Get-Timestamp)] Self-update complete, exiting." -ForegroundColor Green break } if ($script:NatsWebSocket) { try { $script:NatsWebSocket.Dispose() } catch {} $script:NatsWebSocket = $null } if ($script:NatsCancelSource) { try { $script:NatsCancelSource.Dispose() } catch {} $script:NatsCancelSource = $null } Start-Sleep -Seconds 5 } } catch { Write-Host "[$(Get-Timestamp)] Fatal error: $_" -ForegroundColor Red $script:ExitCode = 1 } finally { Write-Host "[$(Get-Timestamp)] Cleaning up..." -ForegroundColor Yellow if ($script:UserId -and $script:BridgeId -and $script:AuthToken) { if ($script:WaitingForNewInstance) { Write-Host "[$(Get-Timestamp)] Self-update exit - skipping tool unregistration (new instance will re-register)" -ForegroundColor Green } else { try { Unregister-BridgeAndTools } catch { Write-Host "[$(Get-Timestamp)] Error unregistering bridge: $_" -ForegroundColor Red } } } if ($script:NatsWebSocket -and $script:NatsWebSocket.State -eq [System.Net.WebSockets.WebSocketState]::Open) { try { $closeTask = $script:NatsWebSocket.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "Closing", $script:NatsCancelToken) while (-not $closeTask.IsCompleted) { Start-Sleep -Milliseconds 100 } Write-Host "[$(Get-Timestamp)] NATS WebSocket closed gracefully" -ForegroundColor Green } catch {} } if ($script:NatsWebSocket) { try { $script:NatsWebSocket.Dispose() } catch {} } if ($script:NatsCancelSource) { try { $script:NatsCancelSource.Dispose() } catch {} } # Cleanup any pending tool runspaces foreach ($entry in $script:PendingJobs.Values) { try { $entry.ps.Stop() } catch {} try { $entry.ps.Dispose() } catch {} try { $entry.runspace.Close() } catch {} try { $entry.runspace.Dispose()} catch {} } $script:PendingJobs.Clear() if ($script:FitaJob) { try { Stop-Job $script:FitaJob; Remove-Job $script:FitaJob } catch {} } Write-Host "[$(Get-Timestamp)] Cleanup complete" -ForegroundColor Green # If running in a console that was opened just for this script (e.g. via Start-Process # or double-click), keep the window open so the user can read the output. # Detection: parent process is explorer.exe, or $Host.Name is ConsoleHost and # the window title was set by us (meaning no pre-existing interactive session). $parentName = "" try { $parentId = (Get-CimInstance Win32_Process -Filter "ProcessId=$PID").ParentProcessId $parentName = (Get-CimInstance Win32_Process -Filter "ProcessId=$parentId").Name } catch {} $isLaunchedStandalone = ($parentName -eq "explorer.exe") -or ($parentName -eq "powershell.exe" -and -not [System.Environment]::UserInteractive -eq $false) # Simpler heuristic: if $Host is ConsoleHost and stdin is NOT a pipe/redirect, # we have a real console - but check if it was opened just for us by looking # at whether there is command history (interactive session would have history). $stdinIsConsole = [Console]::IsInputRedirected -eq $false $hasHistory = $false try { $hasHistory = (Get-History -Count 1).Count -gt 0 } catch {} if ($stdinIsConsole -and -not $hasHistory) { Write-Host "" Write-Host "Press Enter to close..." -ForegroundColor DarkGray $null = Read-Host } }