Public/Install-BATCRelayBot.ps1

#Requires -Version 5.1

function Install-BATCRelayBot {
    <#
    .SYNOPSIS
    Installs BATCRelayBot: checks prerequisites, collects configuration, writes config.json.
 
    .DESCRIPTION
    Six phases, in an order chosen so that nothing the user types can be
    wasted:
 
      1. Detect prerequisites (silent)
      2. Show what was found and what is missing
      3. Resolve missing tools - winget for Python/FFmpeg, guidance for
         VoiceMeeter and BeyondATC, which must be installed by their vendors
      4. Collect Discord credentials and pick the audio device
      5. Confirm the summary
      6. Install and report
 
    Phases 1-3 finish before anything is typed. Until 1.4.0 the credentials
    were collected in phase 3 and the readiness check ran in phase 4, so a
    missing tool discarded the token, server ID and channel ID that had just
    been entered.
 
    No administrator rights are required.
 
    .PARAMETER BotPath
    Installation directory. Defaults to $env:LOCALAPPDATA\BATCRelayBot.
 
    .PARAMETER SkipAudioDevice
    Skips audio device selection and leaves audio_device_name empty. The bot
    will not start until it is filled in - intended for unattended testing.
 
    .PARAMETER PassThru
    Returns the result object. Without it nothing is written to the pipeline,
    so the installation ends with its closing message and not with the install
    paths listed a second time as a table.
 
    .EXAMPLE
    Install-BATCRelayBot
 
    .EXAMPLE
    Install-BATCRelayBot -BotPath "D:\MyBot"
 
    .EXAMPLE
    $result = Install-BATCRelayBot -PassThru
    if ($result.Success) { $result.ConfigPath }
 
    .OUTPUTS
    With -PassThru, a hashtable with Success, and on success InstallPath,
    ConfigPath, LogPath.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [string]$BotPath = (Join-Path $env:LOCALAPPDATA "BATCRelayBot"),
        [switch]$SkipAudioDevice,
        [switch]$PassThru
    )

    $version = Get-ModuleVersion

    Write-Host ""
    Write-Host "=======================================" -ForegroundColor Cyan
    Write-Host " BATCRelayBot Installation (v$version)" -ForegroundColor Cyan
    Write-Host "=======================================" -ForegroundColor Cyan
    Write-Host ""

    # The log has to exist before anything can fail. Creating it later is why
    # earlier versions left no trace of the failures users actually hit.
    $logPath = Initialize-InstallLog -InstallPath $BotPath -Version $version
    if ($logPath) {
        Write-Host "Logfile created under $logPath" -ForegroundColor DarkGray
        Write-Host ""
    }

    try {
        # ---- Phase 0: migrate an existing installation -------------------
        $configPath = Join-Path $BotPath "config.json"
        if (Test-Path $configPath) {
            $migration = Convert-LegacyBotConfig -ConfigPath $configPath
            if ($migration.Migrated) {
                Write-Host "Existing configuration migrated to the current schema:" -ForegroundColor Cyan
                foreach ($change in $migration.Changes) {
                    Write-Host " - $change" -ForegroundColor Gray
                }
                Write-InstallLog "Migrated existing config: $($migration.Changes -join '; ')" -LogPath $logPath
                Write-Host ""
            } elseif ($migration.Error) {
                Write-Host "WARNING: $($migration.Error)" -ForegroundColor Yellow
                Write-Host " It will be replaced by this installation." -ForegroundColor Yellow
                Write-InstallLog "Migration skipped: $($migration.Error)" -LogPath $logPath -Level WARN
                Write-Host ""
            }
        }

        # ---- Phase 1: detection ------------------------------------------
        Write-Host "[1/6] Checking prerequisites..." -ForegroundColor Cyan
        $prerequisites = @{
            Python      = Find-Python
            FFmpeg      = Find-FFmpeg
            VoiceMeeter = Find-VoiceMeeter
            BeyondATC   = Find-BeyondATC
        }
        foreach ($name in @('Python', 'FFmpeg', 'VoiceMeeter', 'BeyondATC')) {
            $item = $prerequisites[$name]
            $detail = if ($item.Found) { $item.Path } else { $item.Reason }
            Write-InstallLog "Detected ${name}: Found=$($item.Found) - $detail" -LogPath $logPath
        }
        Write-Host ""

        # ---- Phase 2: status ---------------------------------------------
        Write-Host "[2/6] Prerequisite status" -ForegroundColor Cyan
        Show-PrerequisitesInfo -Prerequisites $prerequisites

        # ---- Phase 3: resolve what is missing ----------------------------
        Write-Host "[3/6] Resolving missing components" -ForegroundColor Cyan
        Write-Host ""

        $prerequisites = Resolve-MissingTool -Prerequisites $prerequisites -LogPath $logPath

        if (-not ($prerequisites.Python.Found -and $prerequisites.FFmpeg.Found)) {
            # The only place the links appear. Phase 3 used to print them too,
            # so declining the winget offer produced the same block twice.
            Show-ManualInstallLinks -Prerequisites $prerequisites
            Write-InstallLog "Aborted: required tools still missing after phase 3" -LogPath $logPath -Level ERROR
            return (Stop-Installation -Reason "Required tools missing" `
                -LogPath $logPath -PassThru:$PassThru)
        }

        # VoiceMeeter has to come from VB-Audio's own installer, so the most
        # this installer can do is check, explain and let the user decide.
        if (-not $prerequisites.VoiceMeeter.Found) {
            if (-not (Confirm-ContinueWithoutVoiceMeeter -Detail $prerequisites.VoiceMeeter.Reason -LogPath $logPath)) {
                Write-InstallLog "User aborted at the VoiceMeeter warning" -LogPath $logPath
                return (Stop-Installation -Reason "Cancelled - VoiceMeeter missing" `
                    -LogPath $logPath -PassThru:$PassThru)
            }
        }

        # ---- Phase 4: configuration --------------------------------------
        Write-Host "[4/6] Configuration" -ForegroundColor Cyan
        Write-Host ""

        $discordConfig = Get-DiscordConfiguration -LogPath $logPath
        if (-not $discordConfig) {
            Write-Host "Discord configuration was not completed." -ForegroundColor Red
            Write-InstallLog "Aborted: Discord configuration incomplete" -LogPath $logPath -Level ERROR
            return (Stop-Installation -Reason "Discord configuration incomplete" `
                -LogPath $logPath -PassThru:$PassThru)
        }

        if ($SkipAudioDevice) {
            $discordConfig.AudioDeviceName = ""
            Write-InstallLog "Audio device selection skipped by -SkipAudioDevice" -LogPath $logPath -Level WARN
        } else {
            $device = Select-AudioDevice -FFmpegPath $prerequisites.FFmpeg.Path -LogPath $logPath
            if (-not $device) {
                Write-Host "No audio device selected - the bot would join the channel but stream silence." -ForegroundColor Red
                Write-InstallLog "Aborted: no audio device selected" -LogPath $logPath -Level ERROR
                return (Stop-Installation -Reason "No audio device selected" `
                    -LogPath $logPath -PassThru:$PassThru)
            }
            $discordConfig.AudioDeviceName = $device
        }

        # ---- Phase 5: summary --------------------------------------------
        Write-Host "[5/6] Summary" -ForegroundColor Cyan
        $summary = Show-InstallationSummary -Prerequisites $prerequisites `
            -DiscordConfig $discordConfig -InstallPath $BotPath

        if (-not $summary.CanProceed) {
            Write-Host "Installation cannot proceed: $($summary.BlockingReason)" -ForegroundColor Red
            Write-InstallLog "Aborted at summary: $($summary.BlockingReason)" -LogPath $logPath -Level ERROR
            return (Stop-Installation -Reason $summary.BlockingReason `
                -LogPath $logPath -PassThru:$PassThru)
        }

        # ---- Phase 6: install --------------------------------------------
        Write-Host "[6/6] Installing" -ForegroundColor Cyan
        $installResult = Start-Installation -Prerequisites $prerequisites `
            -DiscordConfig $discordConfig -InstallPath $BotPath -LogPath $logPath

        if (-not $installResult.Success) {
            Write-Host ""
            Write-Host "Installation failed: $($installResult.Error)" -ForegroundColor Red
            Write-InstallLog "Installation failed: $($installResult.Error)" -LogPath $logPath -Level ERROR
            return (Stop-Installation -Reason $installResult.Error `
                -LogPath $logPath -PassThru:$PassThru)
        }

        Show-PostInstallationMessage `
            -InstallPath $installResult.InstallPath `
            -ConfigPath $installResult.ConfigPath `
            -LogPath $installResult.LogPath

        Write-InstallLog "Installation completed successfully" -LogPath $logPath
        return (Out-CommandResult -Result $installResult -PassThru:$PassThru)

    } catch {
        # Nothing below this point may call exit: exit inside a module
        # function terminates the whole PowerShell session, which is what
        # made the window close before the error could be read.
        $message = Remove-SensitiveData -Text $_.Exception.Message

        Write-Host ""
        Write-Host "Unexpected error during installation:" -ForegroundColor Red
        Write-Host " $message" -ForegroundColor Red
        Write-Host ""

        Write-InstallLog "Unhandled exception: $message" -LogPath $logPath -Level ERROR
        Write-InstallLog "At: $($_.ScriptStackTrace)" -LogPath $logPath -Level ERROR

        return (Stop-Installation -Reason $message `
            -LogPath $logPath -PassThru:$PassThru)
    }
}

function Stop-Installation {
    <#
    .SYNOPSIS
    Ends the installation without killing the host session.
 
    .DESCRIPTION
    Points the user at the log and pauses, so a double-clicked shortcut does
    not close before the message can be read. The pause is skipped when the
    session is non-interactive, otherwise automated runs would hang forever.
 
    Every failing exit of Install-BATCRelayBot goes through here, so -PassThru
    is honoured in one place rather than at each of the seven return points.
    #>

    [OutputType([hashtable])]
    param(
        [string]$Reason,
        [string]$LogPath,
        [switch]$PassThru
    )

    Write-Host ""
    if ($LogPath -and (Test-Path $LogPath)) {
        Write-Host "Details are in the log: $LogPath" -ForegroundColor Yellow
    }

    if ([Environment]::UserInteractive) {
        Write-Host ""
        Read-Host "Press Enter to close" | Out-Null
    }

    return (Out-CommandResult -PassThru:$PassThru `
        -Result @{ Success = $false; Error = $Reason; LogPath = $LogPath })
}

function Resolve-MissingTool {
    <#
    .SYNOPSIS
    Offers to install missing Python/FFmpeg, then returns updated detection.
    #>

    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)][hashtable]$Prerequisites,
        [string]$LogPath
    )

    $missing = @()
    if (-not $Prerequisites.Python.Found) { $missing += "Python 3.10+" }
    if (-not $Prerequisites.FFmpeg.Found) { $missing += "FFmpeg" }

    if ($missing.Count -eq 0) {
        Write-Host " Python and FFmpeg are present." -ForegroundColor Green
        Write-Host ""
        return $Prerequisites
    }

    # What is missing was already stated, with the reason, in phase 2. Saying
    # it again here is the phase's own heading repeated in longer words.
    #
    # There is no third option. Continuing without Python or FFmpeg produces an
    # installation that cannot run, and the failure then arrives later and
    # further from its cause.
    $answer = Read-Host " Install $($missing -join ' and ') now with winget? (Y/n)"
    $install = [string]::IsNullOrWhiteSpace($answer) -or $answer -match '^(y|yes|j|ja)$'
    Write-InstallLog "Winget offer for $($missing -join ', '): $(if ($install) { 'accepted' } else { 'declined' })" -LogPath $LogPath
    Write-Host ""

    if (-not $install) {
        # Deliberately silent: the caller finds the tools still missing and
        # prints the links, so they appear exactly once either way.
        return $Prerequisites
    }

    return (Install-MissingPrerequisite -Prerequisites $Prerequisites -LogPath $LogPath)
}

function Show-ManualInstallLinks {
    <#
    .SYNOPSIS
    Says where to get whatever is still missing, and stops.
 
    .DESCRIPTION
    The single place these links are printed. They used to live here and in
    the declined-offer branch of Resolve-MissingTool, which is why declining
    the offer showed the same four lines twice in a row.
    #>

    [OutputType([void])]
    param([hashtable]$Prerequisites)

    Write-Host ""
    if (-not $Prerequisites.Python.Found) {
        Write-Host " Python : https://www.python.org/downloads/" -ForegroundColor Yellow
        Write-Host " During setup, tick 'Add python.exe to PATH'." -ForegroundColor Gray
    }
    if (-not $Prerequisites.FFmpeg.Found) {
        Write-Host " FFmpeg : winget install Gyan.FFmpeg" -ForegroundColor Yellow
        Write-Host " or https://ffmpeg.org/download.html" -ForegroundColor Gray
    }
    Write-Host ""
    Write-Host " Run Install-BATCRelayBot again once they are installed." -ForegroundColor Yellow
}

function Confirm-ContinueWithoutVoiceMeeter {
    <#
    .SYNOPSIS
    Explains why VoiceMeeter matters and asks whether to continue anyway.
 
    .DESCRIPTION
    VoiceMeeter must be installed and removed through VB-Audio's own
    installer - it ships audio drivers, so a third party silently installing
    or removing it is not safe. This installer therefore only checks and
    explains.
    #>

    [OutputType([bool])]
    param(
        [string]$Detail,
        [string]$LogPath
    )

    Write-Host " VoiceMeeter was not found." -ForegroundColor Yellow
    if ($Detail) { Write-Host " $Detail" -ForegroundColor DarkGray }
    Write-Host ""
    Write-Host " VoiceMeeter provides the virtual audio device this bot streams from." -ForegroundColor Gray
    Write-Host " Without it there is nothing to relay into Discord." -ForegroundColor Gray
    Write-Host ""
    Write-Host " It has to be installed with VB-Audio's own installer, because it" -ForegroundColor Gray
    Write-Host " ships audio drivers. Short version:" -ForegroundColor Gray
    Write-Host ""
    Write-Host " 1. Download from https://vb-audio.com/Voicemeeter/" -ForegroundColor Cyan
    Write-Host " 2. Run the installer as administrator" -ForegroundColor Cyan
    Write-Host " 3. Reboot - the virtual audio devices only appear afterwards" -ForegroundColor Cyan
    Write-Host " 4. Start VoiceMeeter, then run Install-BATCRelayBot again" -ForegroundColor Cyan
    Write-Host ""
    Write-Host " Continuing now means the audio device list will be missing the" -ForegroundColor Yellow
    Write-Host " VoiceMeeter outputs, so audio_device_name has to be fixed by hand later." -ForegroundColor Yellow
    Write-Host ""

    $answer = Read-Host " Continue anyway? (y/N)"
    $continue = $answer -match '^(y|yes|j|ja)$'
    Write-InstallLog "VoiceMeeter missing - user chose to $(if ($continue) { 'continue' } else { 'abort' })" -LogPath $LogPath
    Write-Host ""
    return $continue
}

Export-ModuleMember -Function @(
    'Install-BATCRelayBot',
    'Resolve-MissingTool',
    'Confirm-ContinueWithoutVoiceMeeter',
    'Show-ManualInstallLinks',
    'Stop-Installation'
)