ScriptBeacon.psm1

Set-StrictMode -Version Latest

#region Globals / Defaults
$script:SBModuleVersion = '0.1.1'
$script:DefaultBaseUri  = $env:SCRIPTBEACON_BASE_URL
if (-not $script:DefaultBaseUri) { $script:DefaultBaseUri = 'https://api.scriptbeacon.com' }

$script:ConfigPath = $env:SCRIPTBEACON_CONFIG
if (-not $script:ConfigPath) {
  $home = [Environment]::GetFolderPath('UserProfile')
  $script:ConfigPath = [IO.Path]::Combine($home, '.scriptbeacon', 'config.json')
}
#endregion

#region Internal: Config + HTTP helpers
function Get-SBConfig {
  [CmdletBinding()] param()
  $cfg = @{
    BaseUri     = $script:DefaultBaseUri
    BeaconId    = $env:SCRIPTBEACON_BEACON_ID
    OrgId       = $env:SCRIPTBEACON_ORG_ID
    ApiKey      = $env:SCRIPTBEACON_API_KEY
    WriteSecret = $env:SCRIPTBEACON_WRITE_SECRET
  }
  if (Test-Path -LiteralPath $script:ConfigPath) {
    try {
      $file = Get-Content -Raw -LiteralPath $script:ConfigPath | ConvertFrom-Json -Depth 4
      foreach ($k in 'BaseUri','BeaconId','OrgId','ApiKey','WriteSecret') {
        if ($file.PSObject.Properties[$k] -and $file.$k) { $cfg[$k] = $file.$k }
      }
    } catch {}
  }
  [pscustomobject]$cfg
}

function Save-SBConfigFile {
  param([psobject]$Config)
  $dir = [IO.Path]::GetDirectoryName($script:ConfigPath)
  if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
  $Config | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $script:ConfigPath -Encoding UTF8
}

function Resolve-SBBaseUri { param([string]$BaseUri) if ($BaseUri) { return $BaseUri.TrimEnd('/') } (Get-SBConfig).BaseUri.TrimEnd('/') }

function Require-SBBeaconId { param([string]$BeaconId) if ([string]::IsNullOrWhiteSpace($BeaconId)) { $BeaconId = (Get-SBConfig).BeaconId } if ([string]::IsNullOrWhiteSpace($BeaconId)) { throw "BeaconId is required. Use Set-SBConfig -BeaconId <uuid> first." } $BeaconId }

function Test-SBFlatPrimitives {
  param([Parameter(Mandatory)][object]$Object)
  if ($null -eq $Object) { return $true }
  if ($Object -is [System.Collections.IDictionary]) {
    foreach ($k in $Object.Keys) {
      $v = $Object[$k]
      if ($null -eq $v) { continue }
      if ($v -is [string] -or $v -is [int] -or $v -is [long] -or $v -is [double] -or $v -is [decimal] -or $v -is [bool]) { continue }
      return $false
    }
    return $true
  } elseif ($Object.PSObject -and $Object.PSObject.Properties.Count -gt 0) {
    foreach ($p in $Object.PSObject.Properties) {
      $v = $p.Value
      if ($null -eq $v) { continue }
      if ($v -is [string] -or $v -is [int] -or $v -is [long] -or $v -is [double] -or $v -is [decimal] -or $v -is [bool]) { continue }
      return $false
    }
    return $true
  }
  return $false
}

function New-SBBodyJson {
  param([hashtable]$Data)
  $MAX_TEXT = 512
  $flat = @{}
  foreach ($kv in $Data.GetEnumerator()) {
    $k = [string]$kv.Key; $v = $kv.Value
    if ($null -eq $v) { $flat[$k] = $null; continue }
    if ($v -is [string])    { $flat[$k] = $v.Substring(0,[Math]::Min($v.Length,$MAX_TEXT)) }
    elseif ($v -is [int] -or $v -is [long] -or $v -is [double] -or $v -is [decimal] -or $v -is [bool]) { $flat[$k] = $v }
    else { throw "Invalid value for key '$k'. Only primitive top-level values are allowed (string/number/bool/null)." }
  }
  $json = $flat | ConvertTo-Json -Depth 2 -Compress
  $bytes = [System.Text.Encoding]::UTF8.GetByteCount($json)
  if ($bytes -gt 16384) { throw "JSON payload is $bytes bytes; maximum allowed is 16384 bytes." }
  $json
}

function Invoke-SBRequest {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)][ValidateSet('GET','POST')][string]$Method,
    [Parameter(Mandatory)][string]$Path,
    [hashtable]$Query,
    [string]$JsonBody,
    [hashtable]$Headers,
    [string]$BaseUri
  )
  $uriBase = Resolve-SBBaseUri -BaseUri $BaseUri
  $uri = "$uriBase$Path"
  if ($Query) {
    $pairs = foreach ($k in $Query.Keys) {
      $v = $Query[$k]
      if ($null -ne $v -and $v -ne '') { "{0}={1}" -f [uri]::EscapeDataString([string]$k), [uri]::EscapeDataString([string]$v) }
    }
    if ($pairs -and $pairs.Count) { $uri = ("{0}`?{1}" -f $uri, ($pairs -join '&')) }  # escape ? in string
  }
  $hdr = @{ 'User-Agent' = "ScriptBeacon-PowerShell/$script:SBModuleVersion"; 'Accept'='application/json' }
  if ($Headers) { foreach ($k in $Headers.Keys) { $hdr[$k] = $Headers[$k] } }
  try {
    if ($Method -eq 'POST') {
      if ($JsonBody) { return Invoke-RestMethod -Method Post -Uri $uri -Headers $hdr -ContentType 'application/json' -Body $JsonBody -ErrorAction Stop }
      else { return Invoke-RestMethod -Method Post -Uri $uri -Headers $hdr -ErrorAction Stop }
    } else {
      return Invoke-RestMethod -Method Get -Uri $uri -Headers $hdr -ErrorAction Stop
    }
  } catch {
    $msg = $_.Exception.Message
    if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $msg = $_.ErrorDetails.Message }
    throw "Request failed: $msg"
  }
}
#endregion

#region Public: Config
function Set-SBConfig {
  [CmdletBinding(SupportsShouldProcess)]
  param(
    [string]$BeaconId,[string]$OrgId,[string]$ApiKey,[string]$BaseUri,[string]$WriteSecret,
    [switch]$Persist,[switch]$PassThru
  )
  $cfg = Get-SBConfig
  if ($PSBoundParameters.ContainsKey('BeaconId'))    { $cfg.BeaconId   = $BeaconId }
  if ($PSBoundParameters.ContainsKey('OrgId'))       { $cfg.OrgId      = $OrgId }
  if ($PSBoundParameters.ContainsKey('ApiKey'))      { $cfg.ApiKey     = $ApiKey }
  if ($PSBoundParameters.ContainsKey('BaseUri'))     { $cfg.BaseUri    = $BaseUri }
  if ($PSBoundParameters.ContainsKey('WriteSecret')) { $cfg.WriteSecret= $WriteSecret }
  if ($Persist) {
    if ($PSCmdlet.ShouldProcess($script:ConfigPath,"Save ScriptBeacon config")) { Save-SBConfigFile -Config $cfg }
  } else {
    if ($cfg.BeaconId)    { $env:SCRIPTBEACON_BEACON_ID   = $cfg.BeaconId }
    if ($cfg.OrgId)       { $env:SCRIPTBEACON_ORG_ID      = $cfg.OrgId }
    if ($cfg.ApiKey)      { $env:SCRIPTBEACON_API_KEY     = $cfg.ApiKey }
    if ($cfg.BaseUri)     { $env:SCRIPTBEACON_BASE_URL    = $cfg.BaseUri }
    if ($cfg.WriteSecret) { $env:SCRIPTBEACON_WRITE_SECRET= $cfg.WriteSecret }
  }
  if ($PassThru) { return (Get-SBConfig) }
}
#endregion

#region Public: Writers (quiet by default; -PassThru returns response)
function Write-SBLog {
  [CmdletBinding()]
  param(
    [Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,
    [ValidateSet('info','warning','error','debug','trace','success')][string]$Level='info',
    [Alias('Tag')][string[]]$Tags,
    [Alias('Fields','Props')][hashtable]$Data,
    [string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru
  )
  $bid = Require-SBBeaconId -BeaconId $BeaconId
  # accept comma-separated tags too
  if ($Tags -and $Tags.Count -eq 1 -and $Tags[0] -is [string] -and $Tags[0] -match ',') { $Tags = $Tags[0].Split(',') | ForEach-Object { $_.Trim() } }
  $body=@{}; if ($PSBoundParameters.ContainsKey('Message')) {$body.message=$Message}; $body.level=$Level
  if ($Tags) { $body.tags = ($Tags -join ',') }
  if ($Data) {
    if (-not (Test-SBFlatPrimitives -Object $Data)) { throw "Data must be a flat object with only primitive values (string/number/bool/null)." }
    foreach ($kv in $Data.GetEnumerator()) { $body[$kv.Key]=$kv.Value }
  }
  $json = New-SBBodyJson -Data $body
  $headers=@{}; $cfg=Get-SBConfig; $ws=$WriteSecret; if (-not $ws) { $ws=$cfg.WriteSecret }; if ($ws) { $headers['X-Beacon-Write']=$ws }
  $res = Invoke-SBRequest -Method POST -Path "/b/$bid" -JsonBody $json -Headers $headers -BaseUri $BaseUri
  if ($PassThru) { return $res } else { $null = $res; return }
}

function Send-SBHeartbeat {
  [CmdletBinding()]
  param(
    [switch]$Alive,[Nullable[datetime]]$Next,[TimeSpan]$Every,[Alias('Fields','Props')][hashtable]$Data,
    [string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru
  )
  $bid = Require-SBBeaconId -BeaconId $BeaconId
  $q=@{}; if ($Every) { $q.next = (Get-Date).ToUniversalTime().Add($Every).ToString('o') } elseif ($Next) { $q.next = ([DateTime]$Next).ToUniversalTime().ToString('o') }
  $body=@{}; if ($Alive) { $body.heartbeat='alive' }
  if (-not $Alive -and $Data.Count -eq 0) { $body.heartbeat='alive' }
  if ($Data) {
    if (-not (Test-SBFlatPrimitives -Object $Data)) { throw "Data must be a flat object with only primitive values (string/number/bool/null)." }
    foreach ($kv in $Data.GetEnumerator()) { $body[$kv.Key]=$kv.Value }
  }
  $json = New-SBBodyJson -Data $body
  $headers=@{}; $cfg=Get-SBConfig; $ws=$WriteSecret; if (-not $ws) { $ws=$cfg.WriteSecret }; if ($ws) { $headers['X-Beacon-Write']=$ws }
  $res = Invoke-SBRequest -Method POST -Path "/b/$bid" -Query $q -JsonBody $json -Headers $headers -BaseUri $BaseUri
  if ($PassThru) { return $res } else { $null = $res; return }
}

function Send-SBInfo    { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { if($PassThru){ return (Write-SBLog -Message $Message -Level info    -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru) } else { $null = Write-SBLog -Message $Message -Level info    -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret } } }
function Send-SBWarning { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { if($PassThru){ return (Write-SBLog -Message $Message -Level warning -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru) } else { $null = Write-SBLog -Message $Message -Level warning -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret } } }
function Send-SBError   { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { if($PassThru){ return (Write-SBLog -Message $Message -Level error   -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru) } else { $null = Write-SBLog -Message $Message -Level error   -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret } } }
function Send-SBDebug   { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { if($PassThru){ return (Write-SBLog -Message $Message -Level debug   -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru) } else { $null = Write-SBLog -Message $Message -Level debug   -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret } } }

Set-Alias -Name Beam-Info      -Value Send-SBInfo
Set-Alias -Name Beam-Warning   -Value Send-SBWarning
Set-Alias -Name Beam-Error     -Value Send-SBError
Set-Alias -Name Beam-Debug     -Value Send-SBDebug
Set-Alias -Name Beam-Heartbeat -Value Send-SBHeartbeat

function Write-SBInfo    { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { $r = Send-SBInfo    -Message $Message -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru:$PassThru; if($PassThru){return $r} } }
function Write-SBWarning { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { $r = Send-SBWarning -Message $Message -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru:$PassThru; if($PassThru){return $r} } }
function Write-SBError   { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { $r = Send-SBError   -Message $Message -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru:$PassThru; if($PassThru){return $r} } }
function Write-SBDebug   { [CmdletBinding()] param([Parameter(Position=0,ValueFromPipeline=$true,ValueFromPipelineByPropertyName=$true)][AllowNull()][string]$Message,[Alias('Tag')][string[]]$Tags,[Alias('Fields','Props')][hashtable]$Data,[string]$BeaconId,[string]$BaseUri,[string]$WriteSecret,[switch]$PassThru) process { $r = Send-SBDebug   -Message $Message -Tags $Tags -Data $Data -BeaconId $BeaconId -BaseUri $BaseUri -WriteSecret $WriteSecret -PassThru:$PassThru; if($PassThru){return $r} } }
#endregion

#region Public: Reads
function Get-SBEvents {
  [CmdletBinding()] param(
    [Parameter(Mandatory)][string]$OrgId,[Parameter(Mandatory)][string]$ApiKey,
    [string]$BeaconId,[int]$Limit=100,[string]$Cursor,[switch]$Before,[datetime]$Since,[datetime]$Until,
    [string]$EventType,[string]$Status,[string]$BaseUri
  )
  $hdr=@{ Authorization="ApiKey $ApiKey" }
  $q=@{ limit=$Limit }; if ($Cursor){$q.cursor=$Cursor}; if ($Before){$q.before='1'}
  if ($BeaconId){$q.bleepId=$BeaconId}; if ($Since){$q.since=$Since.ToUniversalTime().ToString('o')}
  if ($Until){$q.until=$Until.ToUniversalTime().ToString('o')}; if ($EventType){$q.event_type=$EventType}; if ($Status){$q.status=$Status}
  Invoke-SBRequest -Method GET -Path "/org/$OrgId/events" -Query $q -Headers $hdr -BaseUri $BaseUri
}

function Get-SBEventsAll {
  [CmdletBinding()] param(
    [Parameter(Mandatory)][string]$OrgId,[Parameter(Mandatory)][string]$ApiKey,
    [string]$BeaconId,[int]$LimitTotal=1000,[string]$BaseUri
  )
  $all=@(); $cursor=$null
  while ($all.Count -lt $LimitTotal) {
    $take=[Math]::Min(200,$LimitTotal-$all.Count)
    $page = Get-SBEvents -OrgId $OrgId -ApiKey $ApiKey -BeaconId $BeaconId -Limit $take -BaseUri $BaseUri -Cursor $cursor
    if ($page.items) { $all += $page.items }
    if (-not $page.next_cursor) { break }
    $cursor = $page.next_cursor
  }
  $all
}

function Get-SBBeacon {
  [CmdletBinding()] param([Parameter(Mandatory)][string]$BeaconId,[Parameter(Mandatory)][string]$ApiKey,[string]$BaseUri)
  $hdr=@{ Authorization="ApiKey $ApiKey" }
  Invoke-SBRequest -Method GET -Path "/beacon/$BeaconId" -Headers $hdr -BaseUri $BaseUri
}

function Get-SBBeaconStats {
  [CmdletBinding()] param([Parameter(Mandatory)][string]$BeaconId,[Parameter(Mandatory)][string]$ApiKey,[string]$Range='7d',[string]$Bucket='1d',[string]$BaseUri)
  $hdr=@{ Authorization="ApiKey $ApiKey" }; $q=@{ range=$Range; bucket=$Bucket }
  Invoke-SBRequest -Method GET -Path "/bleep/$BeaconId/stats" -Query $q -Headers $hdr -BaseUri $BaseUri
}

function Test-SBConnection {
  [CmdletBinding()] param([Parameter(Mandatory)][string]$OrgId,[Parameter(Mandatory)][string]$ApiKey,[string]$BaseUri)
  $sw=[Diagnostics.Stopwatch]::StartNew()
  try { $null = Get-SBEvents -OrgId $OrgId -ApiKey $ApiKey -Limit 1 -BaseUri $BaseUri; $sw.Stop(); [pscustomobject]@{ ok=$true; ms=$sw.ElapsedMilliseconds } }
  catch { $sw.Stop(); [pscustomobject]@{ ok=$false; ms=$sw.ElapsedMilliseconds; error=$_.Exception.Message } }
}
#endregion

# Export public surface (aliases too)
Export-ModuleMember -Function Get-SBConfig, Set-SBConfig, Write-SBLog, Write-SBInfo, Write-SBWarning, Write-SBError, Write-SBDebug, Send-SBInfo, Send-SBWarning, Send-SBError, Send-SBDebug, Send-SBHeartbeat, Get-SBEvents, Get-SBEventsAll, Get-SBBeacon, Get-SBBeaconStats, Test-SBConnection -Alias Beam-Info, Beam-Warning, Beam-Error, Beam-Debug, Beam-Heartbeat