Private/New-BotConfig.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
Builds and writes config.json in the schema the bot and launcher actually read.
 
.DESCRIPTION
Single source of truth for the config file layout. Two consumers depend on it:
 
  bot.py : bot_token, guild_id, voice_channel_id, audio_device_name
  Start-BATCRelayBot : python_path, voicemeeter_path, voicemeeter_process_name,
                          batc_path, batc_process_name
 
Before 1.4.0 the installer wrote server_id/channel_id and never wrote
audio_device_name at all, so no installed bot could start. Any change here
must keep tests/unit/ConfigContract.Tests.ps1 green - that test derives the
expected keys from the consumers themselves.
#>


function New-BotConfigFile {
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigPath,

        [Parameter(Mandatory = $true)]
        [hashtable]$Prerequisites,

        [Parameter(Mandatory = $true)]
        [hashtable]$DiscordConfig
    )

    # Discord IDs must be JSON numbers: discord.py matches guilds and channels
    # on int, and a quoted ID resolves to None with only a 'not found' log line.
    $guildId = [long]::Parse($DiscordConfig.GuildId)
    $channelId = [long]::Parse($DiscordConfig.VoiceChannelId)

    $vm = $Prerequisites.VoiceMeeter
    $batc = $Prerequisites.BeyondATC

    # Empty rather than absent: Start-BATCRelayBot skips an empty optional
    # component but treats a missing field as a hard error.
    $config = [ordered]@{
        bot_token                 = $DiscordConfig.BotToken
        guild_id                  = $guildId
        voice_channel_id          = $channelId
        audio_device_name         = $DiscordConfig.AudioDeviceName

        python_path               = if ($Prerequisites.Python.Found) { $Prerequisites.Python.Path } else { "" }
        ffmpeg_path               = if ($Prerequisites.FFmpeg.Found) { $Prerequisites.FFmpeg.Path } else { "" }

        voicemeeter_path          = if ($vm.Found -and $vm.ExePath) { $vm.ExePath } else { "" }
        voicemeeter_process_name  = if ($vm.Found -and $vm.ProcessName) { $vm.ProcessName } else { "" }
        voicemeeter_wait_seconds  = 6

        batc_path                 = if ($batc.Found -and $batc.ExePath) { $batc.ExePath } else { "" }
        batc_process_name         = if ($batc.Found -and $batc.ProcessName) { $batc.ProcessName } else { "" }
        batc_wait_seconds         = 8
    }

    $json = $config | ConvertTo-Json -Depth 5
    Write-ConfigFile -ConfigPath $ConfigPath -JsonContent $json | Out-Null
    Protect-BotConfigFile -ConfigPath $ConfigPath | Out-Null

    return @{
        Success    = $true
        ConfigPath = $ConfigPath
        Config     = $config
    }
}

function Protect-BotConfigFile {
    <#
    .SYNOPSIS
    Restricts config.json to the current user (it holds the bot token).
 
    .DESCRIPTION
    Non-fatal by design: on a machine where ACLs cannot be set (network path,
    unusual policy) the installation should still complete. Returns whether
    the restriction was applied so the caller can log it.
    #>

    [OutputType([bool])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigPath
    )

    try {
        # -ErrorAction Stop so a missing file is handled by the catch instead
        # of writing a Get-Acl error into the caller's error stream.
        $acl = Get-Acl -Path $ConfigPath -ErrorAction Stop
        $acl.SetAccessRuleProtection($true, $false)

        $currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
        if (-not $currentUser) { return $false }

        $rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
            $currentUser, 'FullControl', 'Allow')
        $acl.SetAccessRule($rule)
        Set-Acl -Path $ConfigPath -AclObject $acl -ErrorAction Stop
        return $true
    } catch {
        return $false
    }
}

function Convert-LegacyBotConfig {
    <#
    .SYNOPSIS
    Migrates a pre-1.4.0 config.json to the schema the bot can read.
 
    .DESCRIPTION
    Renames server_id -> guild_id and channel_id -> voice_channel_id and
    converts both to JSON numbers. Fields that the old installer never wrote
    (notably audio_device_name) cannot be invented here; the caller is
    responsible for collecting them.
 
    Returns Migrated=$false when there is nothing to do, including when the
    file is absent or unreadable - migration must never block an install.
    #>

    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$ConfigPath
    )

    $result = @{ Migrated = $false; Changes = @(); Error = $null }

    if (-not (Test-Path $ConfigPath)) { return $result }

    try {
        $raw = Get-Content $ConfigPath -Raw -ErrorAction Stop
        $existing = $raw | ConvertFrom-Json -ErrorAction Stop
    } catch {
        $result.Error = "config.json could not be parsed: $($_.Exception.Message)"
        return $result
    }

    $names = @($existing.PSObject.Properties.Name)
    $migrated = [ordered]@{}
    $changes = @()

    foreach ($prop in $existing.PSObject.Properties) {
        switch ($prop.Name) {
            'server_id' {
                if ($names -notcontains 'guild_id') {
                    $migrated['guild_id'] = ConvertTo-DiscordId $prop.Value
                    $changes += "server_id -> guild_id"
                }
            }
            'channel_id' {
                if ($names -notcontains 'voice_channel_id') {
                    $migrated['voice_channel_id'] = ConvertTo-DiscordId $prop.Value
                    $changes += "channel_id -> voice_channel_id"
                }
            }
            default {
                $migrated[$prop.Name] = $prop.Value
            }
        }
    }

    # Quoted IDs are as broken as misnamed ones - normalise them too.
    foreach ($idField in @('guild_id', 'voice_channel_id')) {
        if ($migrated.Contains($idField) -and $migrated[$idField] -is [string]) {
            $converted = ConvertTo-DiscordId $migrated[$idField]
            if ($null -ne $converted) {
                $migrated[$idField] = $converted
                $changes += "$idField converted from string to number"
            }
        }
    }

    if ($changes.Count -eq 0) { return $result }

    try {
        $json = $migrated | ConvertTo-Json -Depth 5
        Write-ConfigFile -ConfigPath $ConfigPath -JsonContent $json | Out-Null
        Protect-BotConfigFile -ConfigPath $ConfigPath | Out-Null
    } catch {
        $result.Error = "config.json could not be rewritten: $($_.Exception.Message)"
        return $result
    }

    $result.Migrated = $true
    $result.Changes = $changes
    return $result
}

function ConvertTo-DiscordId {
    <#
    .SYNOPSIS
    Converts a Discord snowflake to [long], or returns it unchanged if it is
    not a plain numeric string.
    #>

    param($Value)

    if ($null -eq $Value) { return $null }
    if ($Value -is [long] -or $Value -is [int]) { return [long]$Value }

    $text = [string]$Value
    if ($text -match '^\d{15,25}$') { return [long]::Parse($text) }

    return $Value
}

Export-ModuleMember -Function @(
    'New-BotConfigFile',
    'Protect-BotConfigFile',
    'Convert-LegacyBotConfig',
    'ConvertTo-DiscordId'
)