Private/Get-BotProcess.ps1

#Requires -Version 5.1

<#
.SYNOPSIS
Finding and stopping the running bot process.
 
.DESCRIPTION
Shared by Stop-BATCRelayBot and the uninstaller so there is one way to answer
"is the bot running" and "stop it".
 
The detection deliberately does not use Get-Process: a Process object has no
CommandLine property on Windows PowerShell 5.1, so the previous
`Get-Process python | Where-Object { $_.CommandLine -match 'bot\.py' }`
silently matched nothing. The bot was therefore never detected as running and
never stopped - by the uninstaller either, which then deleted files out from
under a live process. Win32_Process does expose CommandLine on 5.1.
 
pythonw.exe matters as much as python.exe: Start-BATCRelayBot launches the
windowless variant.
#>


function Find-BotProcess {
    <#
    .SYNOPSIS
    Finds running bot.py processes belonging to one installation.
 
    .DESCRIPTION
    Matches this installation's own bot.py, so an unrelated Python program is
    never targeted, and neither is a directory that holds no bot at all.
 
    The one case it cannot separate is two installations both running a bare
    "bot.py" from their own working directory with no absolute path and no
    python_path to tell them apart.
 
    .OUTPUTS
    Array of process ids. Empty when nothing matches.
    #>

    [OutputType([int[]])]
    param(
        [Parameter(Mandatory = $true)]
        [string]$BotPath
    )

    $matchesFound = @()

    try {
        $processes = Get-CimInstance Win32_Process `
            -Filter "Name='python.exe' OR Name='pythonw.exe'" -ErrorAction Stop
    } catch {
        return $matchesFound
    }

    $normalisedBotPath = $BotPath.TrimEnd('\')

    foreach ($process in $processes) {
        if (-not $process.CommandLine) { continue }
        if ($process.CommandLine -notmatch 'bot\.py') { continue }

        # An absolute path in the command line is conclusive.
        if ($process.CommandLine -like "*$normalisedBotPath*") {
            $matchesFound += [int]$process.ProcessId
            continue
        }

        # Otherwise the command line is just "bot.py" and the installation
        # directory is the working directory, which Win32_Process does not
        # expose. Fall back to the executable path recorded in config.json.
        try {
            $configPath = Join-Path $normalisedBotPath "config.json"
            if (Test-Path $configPath) {
                $config = Get-Content $configPath -Raw -ErrorAction Stop | ConvertFrom-Json
                $expected = $config.python_path
                if ($expected) {
                    $expectedDir = Split-Path -Parent $expected
                    if ($process.ExecutablePath -and
                        (Split-Path -Parent $process.ExecutablePath) -eq $expectedDir) {
                        $matchesFound += [int]$process.ProcessId
                        continue
                    }
                }
            }
        } catch {}

        # Last resort: a bare "bot.py" command line, started with the
        # installation directory as its working directory, which
        # Win32_Process does not expose.
        #
        # Only for a path that actually holds a bot. Without that condition
        # this claimed any running bot.py for whatever path it was asked
        # about - so asking about an empty directory reported a bot there,
        # and the uninstaller and Stop-BATCRelayBot of one installation would
        # stop another one's process. It also made three tests depend on
        # whether a bot happened to be running on the machine.
        #
        # Two installations both running are still indistinguishable this way
        # when neither writes an absolute path; that needs the pid file, which
        # is what bot.pid is for.
        if (-not (Test-Path (Join-Path $normalisedBotPath 'bot.py'))) { continue }

        if ($process.CommandLine -match '(^|[\s"])bot\.py("|\s|$)') {
            $matchesFound += [int]$process.ProcessId
        }
    }

    return @($matchesFound | Select-Object -Unique)
}

function Stop-BotProcess {
    <#
    .SYNOPSIS
    Stops the bot, preferring the graceful path.
 
    .DESCRIPTION
    Writes stop.signal, which bot.py checks every second: it leaves the voice
    channel cleanly, closes the Discord connection and exits. Only if that
    does not happen within the timeout is the process terminated.
 
    Force-killing a connected voice client leaves the bot visibly stuck in the
    channel for a while, so the graceful path is always tried first.
 
    .OUTPUTS
    Hashtable with Stopped, Method, ProcessIds.
    #>

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

        [int]$TimeoutSeconds = 15
    )

    $running = @(Find-BotProcess -BotPath $BotPath)
    if ($running.Count -eq 0) {
        return @{ Stopped = $true; Method = 'not running'; ProcessIds = @() }
    }

    $stopSignal = Join-Path $BotPath "stop.signal"
    $signalWritten = $false

    if (Test-Path $BotPath) {
        try {
            New-Item -Path $stopSignal -ItemType File -Force -ErrorAction Stop | Out-Null
            $signalWritten = $true
        } catch {
            # Fall through to termination below.
        }
    }

    if ($signalWritten) {
        $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
        while ((Get-Date) -lt $deadline) {
            if (@(Find-BotProcess -BotPath $BotPath).Count -eq 0) {
                Remove-Item $stopSignal -Force -ErrorAction SilentlyContinue
                return @{ Stopped = $true; Method = 'graceful'; ProcessIds = $running }
            }
            Start-Sleep -Milliseconds 500
        }
        Remove-Item $stopSignal -Force -ErrorAction SilentlyContinue
    }

    # Children first. ffmpeg is started by the bot and holds the recording
    # device; killing only the parent leaves it running on a VoiceMeeter bus,
    # where it takes the machine's audio with it until somebody notices.
    $orphans = @()
    try {
        $orphans = @(Get-CimInstance Win32_Process -Filter "Name='ffmpeg.exe'" -ErrorAction Stop |
            Where-Object { $running -contains [int]$_.ParentProcessId } |
            ForEach-Object { [int]$_.ProcessId })
    } catch {}

    foreach ($processId in $running) {
        try {
            Stop-Process -Id $processId -Force -ErrorAction Stop
        } catch {}
    }

    foreach ($orphan in $orphans) {
        try {
            Stop-Process -Id $orphan -Force -ErrorAction Stop
        } catch {}
    }

    $stillRunning = @(Find-BotProcess -BotPath $BotPath)

    return @{
        Stopped    = ($stillRunning.Count -eq 0)
        Method     = 'forced'
        ProcessIds = $running
    }
}

Export-ModuleMember -Function @('Find-BotProcess', 'Stop-BotProcess')