Private/Get-DiscordConfiguration.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
Collects and validates the Discord credentials the bot needs.
 
.DESCRIPTION
Returns a hashtable keyed the way New-BotConfigFile expects:
 
    BotToken, GuildId
 
The names deliberately mirror bot.py's config keys (guild_id) rather than the
Discord UI wording ("Server ID"). The old ServerId/ChannelId naming is what
let the installer write server_id and channel_id into config.json for sixteen
releases without anyone noticing that bot.py reads neither.
 
The voice channel is no longer asked for. The bot joins the channel the
caller is in, or one named in `!BATCjoin`, so a channel chosen at install
time decided nothing and was one more ID to look up before starting.
#>


function Get-DiscordConfiguration {
    [OutputType([hashtable])]
    param(
        [string]$LogPath
    )

    Write-Host "Discord Configuration" -ForegroundColor White -BackgroundColor DarkBlue
    Write-Host "Two values from the Discord developer portal and your server." -ForegroundColor Gray
    Write-Host ""

    $config = @{
        BotToken = $null
        GuildId  = $null
    }

    $token = Read-DiscordToken -LogPath $LogPath
    if (-not $token) { return $null }
    $config.BotToken = $token

    $config.GuildId = Read-DiscordSnowflake `
        -Label "Server ID (guild)" `
        -Step "Step 2/2" `
        -Hint "Discord: Settings > Advanced > Developer Mode, then right-click the server > Copy Server ID" `
        -LogPath $LogPath
    if (-not $config.GuildId) { return $null }

    return $config
}

function Read-DiscordToken {
    <#
    .SYNOPSIS
    Prompts for the bot token and verifies it against the Discord API.
 
    .OUTPUTS
    The token, or $null after three failed attempts.
    #>

    [OutputType([string])]
    param([string]$LogPath)

    $maxAttempts = 3

    for ($attempt = 1; $attempt -le $maxAttempts; $attempt++) {
        Write-Host "Step 1/2: Bot token" -ForegroundColor Cyan
        Write-Host " https://discord.com/developers/applications > your app > Bot > Reset Token" -ForegroundColor Gray

        $secure = Read-Host " Token (hidden)" -AsSecureString
        $plain = ConvertFrom-SecureStringToPlainText -Secure $secure

        if ([string]::IsNullOrWhiteSpace($plain)) {
            Write-Host " No token entered (attempt $attempt/$maxAttempts)." -ForegroundColor Red
            Write-Host ""
            continue
        }

        if ($plain.Length -lt 50) {
            # Real bot tokens are ~59-72 characters. A short value is almost
            # always the application's Client Secret or the Application ID.
            Write-Host " That looks too short for a bot token (attempt $attempt/$maxAttempts)." -ForegroundColor Red
            Write-Host " Make sure you copied the Bot token, not the Client Secret or Application ID." -ForegroundColor Yellow
            Write-InstallLog "Token rejected locally: too short" -LogPath $LogPath -Level WARN
            Write-Host ""
            continue
        }

        Write-Host " Checking the token against the Discord API..." -ForegroundColor Gray
        $validation = Test-DiscordBotToken -Token $plain

        if ($validation.Valid) {
            Write-Host " Token accepted. Bot: $($validation.BotName)" -ForegroundColor Green
            Write-InstallLog "Token validated for bot '$($validation.BotName)'" -LogPath $LogPath
            Write-Host ""
            return $plain
        }

        Write-Host " Rejected (attempt $attempt/$maxAttempts): $($validation.Error)" -ForegroundColor Red
        Write-InstallLog "Token validation failed: $($validation.Error)" -LogPath $LogPath -Level WARN
        Write-Host ""
    }

    Write-Host " Token could not be validated after $maxAttempts attempts." -ForegroundColor Red
    Write-Host ""
    return $null
}

function Read-DiscordSnowflake {
    <#
    .SYNOPSIS
    Prompts for a Discord ID until it is well-formed or the user gives up.
 
    .OUTPUTS
    The ID as a string, or $null if the user entered nothing to abort.
    #>

    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true)][string]$Label,
        [Parameter(Mandatory = $true)][string]$Hint,
        [string]$LogPath,

        # Passed in rather than worked out from the label. It used to pick the
        # numbering itself, out of a total that counted a voice channel
        # question no longer asked - so the caller announced two values and
        # then numbered them out of three. The count belongs where the steps
        # are, which is the caller.
        [string]$Step
    )

    while ($true) {
        $heading = if ($Step) { "${Step}: $Label" } else { $Label }
        Write-Host $heading -ForegroundColor Cyan
        Write-Host " $Hint" -ForegroundColor Gray
        $value = Read-Host " $Label"

        if ([string]::IsNullOrWhiteSpace($value)) {
            Write-Host " Nothing entered - aborting." -ForegroundColor Yellow
            Write-Host ""
            return $null
        }

        $value = $value.Trim()

        # Discord snowflakes are 17-20 digits today and grow over time, so
        # accept a slightly wider range than the current length.
        if ($value -notmatch '^\d{17,21}$') {
            Write-Host " A Discord ID is 17-21 digits with no other characters." -ForegroundColor Red
            Write-Host " Enable Developer Mode first, otherwise 'Copy ID' will not appear." -ForegroundColor Yellow
            Write-Host ""
            continue
        }

        Write-Host " Accepted." -ForegroundColor Green
        Write-InstallLog "$Label accepted: $value" -LogPath $LogPath
        Write-Host ""
        return $value
    }
}

function ConvertFrom-SecureStringToPlainText {
    <#
    .SYNOPSIS
    Reads a SecureString without leaking the unmanaged buffer.
 
    .DESCRIPTION
    SecureStringToCoTaskMemUnicode allocates memory that the caller must free
    again; the previous implementation never did, leaving the token in
    unmanaged memory for the life of the session.
    #>

    [OutputType([string])]
    param([System.Security.SecureString]$Secure)

    if (-not $Secure) { return $null }

    $ptr = [System.IntPtr]::Zero
    try {
        $ptr = [System.Runtime.InteropServices.Marshal]::SecureStringToCoTaskMemUnicode($Secure)
        return [System.Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
    } finally {
        if ($ptr -ne [System.IntPtr]::Zero) {
            [System.Runtime.InteropServices.Marshal]::ZeroFreeCoTaskMemUnicode($ptr)
        }
    }
}

function Test-DiscordBotToken {
    <#
    .SYNOPSIS
    Verifies a bot token against GET /users/@me.
 
    .DESCRIPTION
    The User-Agent must start with "DiscordBot" or Cloudflare blocks the
    request outright, which older versions surfaced as a generic connection
    failure.
    #>

    [OutputType([hashtable])]
    param([string]$Token)

    try {
        $moduleVersion = Get-ModuleVersion

        $headers = @{
            Authorization = "Bot $Token"
            "User-Agent"  = "DiscordBot (https://github.com/ZulshiBLN/BATCRelayBot, $moduleVersion)"
        }

        $response = Invoke-WebRequest -Uri "https://discord.com/api/v10/users/@me" `
            -Headers $headers -Method Get -UseBasicParsing `
            -WarningAction SilentlyContinue -ErrorAction Stop -TimeoutSec 15

        $user = $response.Content | ConvertFrom-Json

        return @{
            Valid   = $true
            BotName = $user.username
            BotId   = $user.id
            Error   = $null
        }
    } catch {
        $statusCode = 0
        if ($_.Exception.PSObject.Properties.Name -contains 'Response' -and $_.Exception.Response) {
            try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { $statusCode = 0 }
        }

        $message = switch ($statusCode) {
            401 { "Discord rejected the token. Reset it in the developer portal and copy the new one." }
            403 { "The token is valid but the bot lacks permission. Check its role and scopes." }
            404 { "Not a bot token - make sure you copied it from the Bot tab, not OAuth2." }
            429 { "Discord is rate limiting this machine. Wait a moment and try again." }
            500 { "Discord had a server error. Try again shortly." }
            503 { "The Discord API is temporarily unavailable. Try again shortly." }
            default {
                # Sanitised: the raw exception can echo the token back.
                $raw = Remove-SensitiveData -Text $_.Exception.Message
                if ($raw -match 'timed out|timeout|No such host|remote name') {
                    "Could not reach the Discord API. Check the internet connection or a proxy."
                } else {
                    "Could not reach the Discord API ($raw)"
                }
            }
        }

        return @{
            Valid   = $false
            BotName = $null
            BotId   = $null
            Error   = $message
        }
    }
}

Export-ModuleMember -Function @(
    'Get-DiscordConfiguration',
    'Test-DiscordBotToken',
    'Read-DiscordToken',
    'Read-DiscordSnowflake',
    'ConvertFrom-SecureStringToPlainText'
)