Private/Get-ConfigFieldMap.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
The single definition of which config fields the editor may change.
 
.DESCRIPTION
This map used to be written out separately in Update-ConfigJson,
Verify-ConfigChange, Test-ConfigValue and Show-ConfigEditorMenu. They drifted:
the installer wrote `bot_token` while the editor read and wrote `token`, so
an edited token was saved to a field nothing read, and the bot kept using the
old one. Keeping one definition is what stops that recurring.
 
Only fields bot.py actually consumes are listed. `output_format` and
`bot_activity` were editable in earlier versions but appear nowhere in
bot.py, so editing them changed nothing.
 
Type matters as much as the name: since 1.4.0 the Discord IDs are JSON
numbers. Writing one back as a string leaves the bot unable to resolve the
guild or channel, with nothing in the log but "not found".
#>


function Get-ConfigFieldMap {
    <#
    .OUTPUTS
    Ordered hashtable: editor field name -> @{ Json; Type; Label; Hint }
    #>

    [OutputType([System.Collections.Specialized.OrderedDictionary])]
    param()

    return [ordered]@{
        Token = @{
            Json  = 'bot_token'
            Type  = 'string'
            Label = 'Bot token'
            Hint  = 'https://discord.com/developers/applications > your app > Bot > Reset Token'
        }
        Guild = @{
            Json  = 'guild_id'
            Type  = 'long'
            Label = 'Server ID'
            Hint  = 'Enable Developer Mode, then right-click the server > Copy Server ID'
        }
        Channel = @{
            Json  = 'voice_channel_id'
            Type  = 'long'
            Label = 'Voice channel ID'
            Hint  = 'Right-click the VOICE channel the bot should join > Copy Channel ID'
        }
        AudioDevice = @{
            Json  = 'audio_device_name'
            Type  = 'string'
            Label = 'Audio device'
            Hint  = 'The VoiceMeeter bus the bot captures, exactly as ffmpeg names it'
        }
    }
}

function Get-ConfigFieldDefinition {
    <#
    .SYNOPSIS
    Looks up one field, throwing on an unknown name rather than writing
    silently to $null.
    #>

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

    $map = Get-ConfigFieldMap
    if (-not $map.Contains($Field)) {
        throw "Unknown config field '$Field'. Known fields: $($map.Keys -join ', ')"
    }
    return $map[$Field]
}

function ConvertTo-ConfigFieldValue {
    <#
    .SYNOPSIS
    Coerces a value to the type its config field requires.
 
    .DESCRIPTION
    Everything arrives from Read-Host as a string. A field typed 'long' has to
    reach ConvertTo-Json as a number, otherwise the ID is quoted and
    discord.py resolves nothing.
    #>

    param(
        [Parameter(Mandatory = $true)][string]$Field,
        $Value
    )

    $definition = Get-ConfigFieldDefinition -Field $Field

    if ($definition.Type -eq 'long') {
        $text = ([string]$Value).Trim()
        if ($text -notmatch '^\d+$') {
            throw "$($definition.Label) must be numeric, got '$Value'"
        }
        return [long]::Parse($text)
    }

    return [string]$Value
}

Export-ModuleMember -Function @(
    'Get-ConfigFieldMap',
    'Get-ConfigFieldDefinition',
    'ConvertTo-ConfigFieldValue'
)