PirateTok.Live.psm1

#!/usr/bin/env pwsh
# PirateTok.Live -- TikTok Live WebSocket connector module
# Install: Install-Module PirateTok.Live
# Usage: Import-Module PirateTok.Live
# $conn = Connect-TikTokLive -Username "someone"
# while ($frame = Receive-TikTokFrame $conn) { ... }

$ErrorActionPreference = 'Stop'

# TLS 1.2 for PS 5.1
try { [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 } catch {}
Add-Type -AssemblyName System.IO.Compression 2>$null

# --- config ---
$script:CDN = "webcast-ws.tiktok.com"
$script:HeartbeatSec = 10

# --- error types (Add-Type so the type is globally visible, not module-scoped) ---
if (-not ([System.Management.Automation.PSTypeName]'TikTokLiveException').Type) {
    Add-Type -Language CSharp -TypeDefinition @'
using System;
public class TikTokLiveException : Exception {
    public string ErrorKind { get; }
    public TikTokLiveException(string kind, string message) : base(message) { ErrorKind = kind; }
}
'@

}

function New-TikTokError([string]$Kind, [string]$Message) {
    return [TikTokLiveException]::new($Kind, $Message)
}

# --- UA pool (3 Firefox, 3 Chrome, mixed OS) ---
$script:UserAgents = @(
    "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:138.0) Gecko/20100101 Firefox/138.0"
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:139.0) Gecko/20100101 Firefox/139.0"
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36"
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
)

function Get-RandomUA {
    return $script:UserAgents[(Get-Random -Minimum 0 -Maximum $script:UserAgents.Count)]
}

function Get-SystemTimezone {
    # 1. TZ env var
    $tz = $env:TZ
    if ($tz -and $tz.Contains('/')) { return $tz.Trim() }

    # 2. .NET TimeZoneInfo → IANA (PS 7+ on all platforms, PS 5.1 on Windows via mapping)
    try {
        $tzi = [System.TimeZoneInfo]::Local
        # PS 7.x exposes .Id as IANA on non-Windows, or has HasIanaId
        if ($tzi.Id -and $tzi.Id.Contains('/')) { return $tzi.Id }
        # Windows: Id is Windows tz name, try to map via .NET 6+ API
        try {
            $iana = $null
            if ([System.TimeZoneInfo]::TryConvertWindowsIdToIanaId($tzi.Id, [ref]$iana)) {
                if ($iana -and $iana.Contains('/')) { return $iana }
            }
        } catch {}
    } catch {}

    # 3. /etc/timezone (Debian/Ubuntu)
    try {
        if (Test-Path "/etc/timezone") {
            $tz = (Get-Content "/etc/timezone" -Raw).Trim()
            if ($tz -and $tz.Contains('/')) { return $tz }
        }
    } catch {}

    # 4. /etc/localtime symlink (Arch, Fedora, macOS)
    try {
        $target = [System.IO.File]::ReadAllText("/etc/localtime")
    } catch {}
    try {
        $link = (Get-Item "/etc/localtime" -ErrorAction SilentlyContinue).Target
        if ($link) {
            $parts = $link -split '/zoneinfo/'
            if ($parts.Count -ge 2 -and $parts[1]) { return $parts[1] }
        }
    } catch {}

    return "UTC"
}

# --- byte helper ---
function Join-Bytes {
    $ms = [System.IO.MemoryStream]::new()
    foreach ($a in $args) { if ($a -and $a.Length -gt 0) { $ms.Write($a, 0, $a.Length) } }
    $r = $ms.ToArray(); $ms.Dispose(); return ,$r
}

# --- protobuf encode ---
function Encode-Varint([uint64]$v) {
    $b = [System.Collections.Generic.List[byte]]::new()
    while ($v -gt 127) {
        $b.Add([byte](($v -band 0x7F) -bor 0x80))
        $v = $v -shr 7
    }
    $b.Add([byte]$v)
    return ,[byte[]]$b.ToArray()
}

function Encode-Tag([int]$f, [int]$w) { return ,(Encode-Varint ([uint64]($f -shl 3 -bor $w))) }

function Encode-LD([int]$field, [byte[]]$payload) {
    return ,(Join-Bytes (Encode-Tag $field 2) (Encode-Varint ([uint64]$payload.Length)) $payload)
}

function Encode-Str([int]$field, [string]$s) {
    return ,(Encode-LD $field ([System.Text.Encoding]::UTF8.GetBytes($s)))
}

function Encode-UInt([int]$field, [uint64]$v) {
    return ,(Join-Bytes (Encode-Tag $field 0) (Encode-Varint $v))
}

# --- protobuf decode ---
function Decode-Varint([byte[]]$data, [int]$off) {
    [uint64]$r = 0; $s = 0; $p = $off
    do {
        $byte = $data[$p]
        $r = $r -bor (([uint64]($byte -band 0x7F)) -shl $s)
        $s += 7; $p++
    } while ($byte -band 0x80)
    return @{ V = $r; O = $p }
}

function Decode-Proto([byte[]]$data) {
    $fields = [System.Collections.Generic.List[hashtable]]::new()
    $off = 0
    while ($off -lt $data.Length) {
        $tv = Decode-Varint $data $off
        $tag = $tv.V; $off = $tv.O
        $fn = [int]($tag -shr 3); $wt = [int]($tag -band 7)
        if ($fn -eq 0) { break }
        switch ($wt) {
            0 { $vv = Decode-Varint $data $off; $off = $vv.O
                $fields.Add(@{ F=$fn; W=0; V=$vv.V }) }
            2 { $lv = Decode-Varint $data $off; $len=[int]$lv.V; $off=$lv.O
                $d = [byte[]]::new([Math]::Max($len,0))
                if ($len -gt 0) { [Array]::Copy($data, $off, $d, 0, $len) }
                $off += $len
                $fields.Add(@{ F=$fn; W=2; V=$d }) }
            1 { $off += 8 }
            5 { $off += 4 }
            default { break }
        }
    }
    return ,$fields.ToArray()
}

function Get-PField($fields, [int]$n) {
    foreach ($f in $fields) { if ($f.F -eq $n) { return ,$f.V } }
    return $null
}

function Get-PAllFields($fields, [int]$n) {
    $results = [System.Collections.Generic.List[object]]::new()
    foreach ($f in $fields) { if ($f.F -eq $n) { $results.Add($f.V) } }
    return ,$results.ToArray()
}

function Get-PStr($fields, [int]$n) {
    $v = Get-PField $fields $n
    if ($v -is [byte[]]) { return [System.Text.Encoding]::UTF8.GetString($v) }
    return $null
}

# --- gzip ---
function Expand-Gz([byte[]]$data) {
    $mi = [System.IO.MemoryStream]::new($data)
    $gs = [System.IO.Compression.GZipStream]::new($mi, [System.IO.Compression.CompressionMode]::Decompress)
    $mo = [System.IO.MemoryStream]::new()
    $gs.CopyTo($mo); $gs.Dispose(); $mi.Dispose()
    $r = $mo.ToArray(); $mo.Dispose(); return ,$r
}

# --- frame builders ---
function New-Heartbeat([uint64]$rid) {
    return ,(Join-Bytes (Encode-Str 6 "pb") (Encode-Str 7 "hb") (Encode-LD 8 (Encode-UInt 1 $rid)))
}

function New-EnterRoom([uint64]$rid) {
    $inner = Join-Bytes (Encode-UInt 1 $rid) (Encode-UInt 4 12) (Encode-Str 5 "audience") (Encode-Str 9 "0")
    return ,(Join-Bytes (Encode-Str 6 "pb") (Encode-Str 7 "im_enter_room") (Encode-LD 8 $inner))
}

function New-Ack([uint64]$logId, [byte[]]$ext) {
    return ,(Join-Bytes (Encode-Str 6 "pb") (Encode-Str 7 "ack") (Encode-UInt 2 $logId) (Encode-LD 8 $ext))
}

# --- badge decoder ---
function Read-TikTokBadge([byte[]]$data) {
    $f = Decode-Proto $data
    $badge = @{
        DisplayType = Get-PField $f 1
        BadgeScene  = Get-PField $f 3
        Display     = Get-PField $f 11
        Level       = $null
    }
    $logExtra = Get-PField $f 12
    if ($logExtra -is [byte[]]) {
        $lf = Decode-Proto $logExtra
        $badge.Level = Get-PStr $lf 5
    }
    return $badge
}

# --- user decoder (enriched) ---
function Read-TikTokUser([byte[]]$data) {
    $f = Decode-Proto $data
    $user = @{
        UserId       = Get-PField $f 1
        Nickname     = Get-PStr $f 3
        Bio          = Get-PStr $f 5
        Verified     = $false
        UniqueId     = Get-PStr $f 38
        DisplayId    = Get-PStr $f 46
        TopVipNo     = Get-PField $f 31
        PayScore     = Get-PField $f 34
        FanTicket    = Get-PField $f 35
        FollowStatus = Get-PField $f 1024
        IsFollower   = $false
        IsFollowing  = $false
        IsSubscribe  = $false
        GifterLevel  = $null
        MemberLevel  = $null
        IsModerator  = $false
        IsTopGifter  = $false
        FansClubName = $null
        FansClubLevel = $null
        FollowingCount = $null
        FollowerCount  = $null
    }

    $verified = Get-PField $f 12
    if ($verified -ne $null -and $verified -ne 0) { $user.Verified = $true }

    $isFollower = Get-PField $f 1029
    if ($isFollower -ne $null -and $isFollower -ne 0) { $user.IsFollower = $true }

    $isFollowing = Get-PField $f 1030
    if ($isFollowing -ne $null -and $isFollowing -ne 0) { $user.IsFollowing = $true }

    $isSub = Get-PField $f 1090
    if ($isSub -ne $null -and $isSub -ne 0) { $user.IsSubscribe = $true }

    # FollowInfo (tag 22)
    $followInfo = Get-PField $f 22
    if ($followInfo -is [byte[]]) {
        $fi = Decode-Proto $followInfo
        $user.FollowingCount = Get-PField $fi 1
        $user.FollowerCount = Get-PField $fi 2
        $fiStatus = Get-PField $fi 3
        if ($fiStatus -ne $null) { $user.FollowStatus = $fiStatus }
    }

    # FansClub (tag 24 -> tag 1)
    $fansClub = Get-PField $f 24
    if ($fansClub -is [byte[]]) {
        $fc = Decode-Proto $fansClub
        $clubData = Get-PField $fc 1
        if ($clubData -is [byte[]]) {
            $cd = Decode-Proto $clubData
            $user.FansClubName = Get-PStr $cd 1
            $user.FansClubLevel = Get-PField $cd 2
        }
    }

    # Badges (tag 64, repeated)
    $badgeBytes = Get-PAllFields $f 64
    foreach ($bb in $badgeBytes) {
        if ($bb -is [byte[]]) {
            $badge = Read-TikTokBadge $bb
            switch ($badge.BadgeScene) {
                1  { $user.IsModerator = $true }
                6  { $user.IsTopGifter = $true }
                8  { if ($badge.Level) { $user.GifterLevel = $badge.Level } }
                10 { if ($badge.Level) { $user.MemberLevel = $badge.Level } }
            }
        }
    }

    return $user
}

# --- WSS URL builder ---
function Build-WssUrl([string]$cdn, [uint64]$roomId) {
    $tz = Get-SystemTimezone
    $rtt = "{0:F3}" -f (100 + (Get-Random -Minimum 0 -Maximum 100))
    $params = @(
        "version_code=180800"
        "device_platform=web"
        "cookie_enabled=true"
        "screen_width=1920"
        "screen_height=1080"
        "browser_language=en-US"
        "browser_platform=Linux+x86_64"
        "browser_name=Mozilla"
        "browser_version=5.0+(X11)"
        "browser_online=true"
        "tz_name=$([Uri]::EscapeDataString($tz))"
        "app_name=tiktok_web"
        "sup_ws_ds_opt=1"
        "update_version_code=2.0.0"
        "compress=gzip"
        "webcast_language=en"
        "ws_direct=1"
        "aid=1988"
        "live_id=12"
        "app_language=en"
        "client_enter=1"
        "room_id=$roomId"
        "identity=audience"
        "history_comment_count=6"
        "last_rtt=$rtt"
        "heartbeat_duration=10000"
        "resp_content_type=protobuf"
        "did_rule=3"
    ) -join '&'
    return "wss://$cdn/webcast/im/ws_proxy/ws_reuse_supplement/?$params"
}

# --- public functions ---

function Get-TikTokTtwid {
    [CmdletBinding()]
    param(
        [string]$UserAgent
    )
    $ua = if ($UserAgent) { $UserAgent } else { Get-RandomUA }
    $null = Invoke-WebRequest -Uri "https://www.tiktok.com/" -UserAgent $ua -SessionVariable s -UseBasicParsing
    $c = $s.Cookies.GetCookies("https://www.tiktok.com/") | Where-Object Name -eq "ttwid"
    if (-not $c) { throw (New-TikTokError "ConnectFailed" "no ttwid cookie returned from tiktok.com") }
    return $c.Value
}

function Get-TikTokRoomId {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [string]$Username,
        [string]$UserAgent
    )
    $Username = $Username.TrimStart('@')
    $ua = if ($UserAgent) { $UserAgent } else { Get-RandomUA }
    $url = "https://www.tiktok.com/api-live/user/room?aid=1988&app_name=tiktok_web&device_platform=web_pc&app_language=en&browser_language=en-US&user_is_login=false&sourceType=54&uniqueId=$Username"

    try {
        $resp = Invoke-WebRequest -Uri $url -UserAgent $ua -UseBasicParsing
    } catch {
        $status = $_.Exception.Response.StatusCode.value__
        if ($status -eq 403 -or $status -eq 429) {
            throw (New-TikTokError "TikTokBlocked" "HTTP $status — rate-limited or geo-blocked")
        }
        throw (New-TikTokError "ConnectFailed" "HTTP request failed: $($_.Exception.Message)")
    }

    if (-not $resp.Content -or $resp.Content.Length -eq 0) {
        throw (New-TikTokError "TikTokBlocked" "empty response — IP or fingerprint blocked")
    }

    try {
        $json = $resp.Content | ConvertFrom-Json
    } catch {
        throw (New-TikTokError "TikTokBlocked" "non-JSON response — IP or fingerprint blocked")
    }

    switch ($json.statusCode) {
        0 {}
        19881007 { throw (New-TikTokError "UserNotFound" "'$Username' does not exist") }
        default  { throw (New-TikTokError "ApiError" "statusCode=$($json.statusCode)") }
    }

    $rid = $json.data.user.roomId
    if (-not $rid -or $rid -eq "0") {
        throw (New-TikTokError "HostNotOnline" "'$Username' is not currently live")
    }

    $liveStatus = $json.data.liveRoom.status
    if ($null -eq $liveStatus) { $liveStatus = $json.data.user.status }
    if ($null -ne $liveStatus -and $liveStatus -ne 2) {
        throw (New-TikTokError "HostNotOnline" "'$Username' is not currently live (status=$liveStatus)")
    }

    return [uint64]$rid
}

function Get-TikTokStreamInfo {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [uint64]$RoomId,
        [string]$Cookies = "",
        [string]$UserAgent
    )
    $ua = if ($UserAgent) { $UserAgent } else { Get-RandomUA }
    $tz = [Uri]::EscapeDataString((Get-SystemTimezone))
    $url = "https://webcast.tiktok.com/webcast/room/info/?aid=1988&app_name=tiktok_web&device_platform=web_pc&app_language=en&browser_language=en-US&tz_name=$tz&room_id=$RoomId"
    $headers = @{ "User-Agent" = $ua }
    if ($Cookies) { $headers["Cookie"] = $Cookies }

    try {
        $resp = Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing
    } catch {
        $status = $_.Exception.Response.StatusCode.value__
        if ($status -eq 403 -or $status -eq 429) {
            throw (New-TikTokError "TikTokBlocked" "HTTP $status — rate-limited or geo-blocked")
        }
        throw (New-TikTokError "ConnectFailed" "room info fetch failed: $($_.Exception.Message)")
    }

    $json = $resp.Content | ConvertFrom-Json
    if ($json.status_code -eq 4003110) {
        throw (New-TikTokError "AgeRestricted" "18+ room — pass session cookies via -Cookies 'sessionid=xxx; sid_tt=xxx'")
    }
    if ($json.status_code -and $json.status_code -ne 0) {
        throw (New-TikTokError "ApiError" "room/info status_code=$($json.status_code)")
    }
    return $json.data
}

function Get-TikTokBestStreamUrl {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [object]$RoomInfo,
        [ValidateSet("origin","hd","sd","ld","ao")]
        [string]$Quality = "origin"
    )
    $sdStr = $RoomInfo.stream_url.live_core_sdk_data.pull_data.stream_data
    if (-not $sdStr) { return $null }
    try { $nested = $sdStr | ConvertFrom-Json } catch { return $null }

    $tiers = @("origin","hd","uhd","sd","ld","ao")
    $startIdx = [Array]::IndexOf($tiers, $Quality)
    if ($startIdx -lt 0) { $startIdx = 0 }

    for ($i = $startIdx; $i -lt $tiers.Count; $i++) {
        $tier = $tiers[$i]
        $flv = $nested.data.$tier.main.flv
        if ($flv) { return @{ Quality = $tier; Url = $flv } }
    }
    return $null
}

function Connect-TikTokLive {
    [CmdletBinding()]
    param(
        [Parameter(Position=0)]
        [string]$Username,
        [uint64]$RoomId = 0,
        [string]$Ttwid = "",
        [string]$UserAgent,
        [string]$Cookies = "",
        [int]$HeartbeatInterval = 10
    )

    $ua = if ($UserAgent) { $UserAgent } else { Get-RandomUA }

    if (-not $Ttwid) {
        $Ttwid = Get-TikTokTtwid -UserAgent $ua
    }

    if ($RoomId -eq 0) {
        if (-not $Username) {
            throw (New-TikTokError "ConnectFailed" "provide -Username or -RoomId")
        }
        $Username = $Username.TrimStart('@')
        $RoomId = Get-TikTokRoomId $Username -UserAgent $ua
    }

    $wsUrl = Build-WssUrl $script:CDN $RoomId

    $conn = @{
        Username  = $Username
        RoomId    = $RoomId
        Ttwid     = $Ttwid
        UserAgent = $ua
        RawMode   = $false
        LastHb    = [DateTime]::UtcNow
        HbSec     = $HeartbeatInterval
        Ws        = $null
        Tcp       = $null
        Ssl       = $null
    }

    $cookieHeader = "ttwid=$Ttwid"
    if ($Cookies) { $cookieHeader = "$cookieHeader; $Cookies" }

    try {
        $ws = [System.Net.WebSockets.ClientWebSocket]::new()
        try { $ws.Options.SetRequestHeader("User-Agent", $ua) } catch {}
        try { $ws.Options.SetRequestHeader("Origin", "https://www.tiktok.com") } catch {}
        $ck = [System.Net.CookieContainer]::new()
        $ck.Add([System.Net.Cookie]::new("ttwid", $Ttwid, "/", ".tiktok.com"))
        $ws.Options.Cookies = $ck
        $wsUri = [Uri]$wsUrl
        $ws.ConnectAsync($wsUri, [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() | Out-Null
        $conn.Ws = $ws
    } catch {
        # PS 5.1 / Win7 fallback: TcpClient + SslStream + hand-rolled WS framing
        $conn.RawMode = $true
        $wsPath = $wsUrl -replace '^wss://[^/]+', ''
        $tcp = [System.Net.Sockets.TcpClient]::new($script:CDN, 443)
        $ssl = [System.Net.Security.SslStream]::new($tcp.GetStream(), $false)
        $ssl.AuthenticateAsClient($script:CDN, $null, [System.Security.Authentication.SslProtocols]::Tls12, $false)
        $rkey = [byte[]]::new(16); ([System.Random]::new()).NextBytes($rkey)
        $b64 = [Convert]::ToBase64String($rkey)
        $hdr = "GET $wsPath HTTP/1.1`r`nHost: $($script:CDN)`r`nUpgrade: websocket`r`nConnection: Upgrade`r`nSec-WebSocket-Key: ${b64}`r`nSec-WebSocket-Version: 13`r`nUser-Agent: $ua`r`nOrigin: https://www.tiktok.com`r`nCookie: $cookieHeader`r`n`r`n"
        $hb = [System.Text.Encoding]::ASCII.GetBytes($hdr)
        $ssl.Write($hb, 0, $hb.Length); $ssl.Flush()
        $rb = [byte[]]::new(4096); $n = $ssl.Read($rb, 0, $rb.Length)
        $rs = [System.Text.Encoding]::ASCII.GetString($rb, 0, $n)
        if ($rs -match "DEVICE_BLOCKED") {
            try { $ssl.Dispose() } catch {}
            try { $tcp.Dispose() } catch {}
            throw (New-TikTokError "DeviceBlocked" "DEVICE_BLOCKED — fetch fresh ttwid and retry")
        }
        if ($rs -notmatch "101") {
            try { $ssl.Dispose() } catch {}
            try { $tcp.Dispose() } catch {}
            throw (New-TikTokError "ConnectFailed" "WS handshake failed: $($rs.Split("`n")[0])")
        }
        $conn.Tcp = $tcp
        $conn.Ssl = $ssl
    }

    # send heartbeat + enter room
    Send-TikTokWsRaw $conn (New-Heartbeat $RoomId)
    Start-Sleep -Milliseconds 200
    Send-TikTokWsRaw $conn (New-EnterRoom $RoomId)

    return $conn
}

# --- internal ws send/receive (not exported) ---
function Send-TikTokWsRaw($conn, [byte[]]$data) {
    if ($conn.RawMode) {
        $len = $data.Length; $h = [System.Collections.Generic.List[byte]]::new()
        $h.Add(0x82)
        if ($len -lt 126) { $h.Add([byte](0x80 -bor $len)) }
        elseif ($len -lt 65536) { $h.Add(0xFE); $h.Add([byte]($len -shr 8)); $h.Add([byte]($len -band 0xFF)) }
        $mask = [byte[]]::new(4); ([System.Random]::new()).NextBytes($mask); $h.AddRange($mask)
        $masked = [byte[]]::new($len)
        for ($i = 0; $i -lt $len; $i++) { $masked[$i] = $data[$i] -bxor $mask[$i % 4] }
        $all = Join-Bytes $h.ToArray() $masked
        $conn.Ssl.Write($all, 0, $all.Length); $conn.Ssl.Flush()
    } else {
        $seg = [ArraySegment[byte]]::new($data)
        $conn.Ws.SendAsync($seg, [System.Net.WebSockets.WebSocketMessageType]::Binary, $true,
            [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() | Out-Null
    }
}

function Receive-TikTokWsRaw($conn, [int]$timeoutMs = 2000) {
    if ($conn.RawMode) {
        $conn.Tcp.ReceiveTimeout = $timeoutMs
        try {
            $hdr = [byte[]]::new(2)
            $n = $conn.Ssl.Read($hdr, 0, 2); if ($n -lt 2) { return $null }
            if (($hdr[0] -band 0x0F) -eq 8) { return $null }
            [int]$plen = $hdr[1] -band 0x7F
            if ($plen -eq 126) {
                $ext = [byte[]]::new(2); $conn.Ssl.Read($ext, 0, 2) | Out-Null
                $plen = ([int]$ext[0] -shl 8) -bor $ext[1]
            } elseif ($plen -eq 127) {
                $ext = [byte[]]::new(8); $conn.Ssl.Read($ext, 0, 8) | Out-Null
                [int64]$plen = 0; for ($i = 0; $i -lt 8; $i++) { $plen = ($plen -shl 8) -bor $ext[$i] }
            }
            $payload = [byte[]]::new($plen); $rd = 0
            while ($rd -lt $plen) {
                $n = $conn.Ssl.Read($payload, $rd, $plen - $rd)
                if ($n -le 0) { return $null }; $rd += $n
            }
            return ,$payload
        } catch [System.IO.IOException] { return ,[byte[]]@() }
        catch { return $null }
    } else {
        $buf = [byte[]]::new(65536)
        $ms = [System.IO.MemoryStream]::new()
        $cts = [System.Threading.CancellationTokenSource]::new($timeoutMs)
        try {
            do {
                $seg = [ArraySegment[byte]]::new($buf)
                $res = $conn.Ws.ReceiveAsync($seg, $cts.Token).GetAwaiter().GetResult()
                if ($res.MessageType -eq [System.Net.WebSockets.WebSocketMessageType]::Close) { return $null }
                $ms.Write($buf, 0, $res.Count)
            } while (-not $res.EndOfMessage)
            return ,[byte[]]$ms.ToArray()
        } catch [System.OperationCanceledException] { return ,[byte[]]@() }
        catch { return $null }
        finally { $cts.Dispose(); $ms.Dispose() }
    }
}

function Send-TikTokHeartbeat {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [hashtable]$Connection
    )
    Send-TikTokWsRaw $Connection (New-Heartbeat $Connection.RoomId)
    $Connection.LastHb = [DateTime]::UtcNow
}

function Receive-TikTokFrame {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [hashtable]$Connection,
        [int]$TimeoutMs = 2000
    )

    # auto-heartbeat
    if (([DateTime]::UtcNow - $Connection.LastHb).TotalSeconds -ge $Connection.HbSec) {
        Send-TikTokHeartbeat $Connection
    }

    $raw = Receive-TikTokWsRaw $Connection $TimeoutMs
    if ($null -eq $raw) { return $null }
    if ($raw.Length -eq 0) { return ,@([hashtable[]]@()) }

    $outer = Decode-Proto $raw
    $ptype = Get-PStr $outer 7
    if ($ptype -ne "msg") { return ,@([hashtable[]]@()) }

    $payload = Get-PField $outer 8
    if (-not ($payload -is [byte[]])) { return ,@([hashtable[]]@()) }

    # gzip decompress
    if ($payload.Length -ge 2 -and $payload[0] -eq 0x1f -and $payload[1] -eq 0x8b) {
        $payload = Expand-Gz $payload
    }

    $resp = Decode-Proto $payload

    # ack if needed
    $needAck = Get-PField $resp 9
    $intExt = Get-PField $resp 5
    if ($needAck -eq 1 -and $intExt -is [byte[]]) {
        $logId = Get-PField $outer 2
        if ($logId) {
            try { Send-TikTokWsRaw $Connection (New-Ack ([uint64]$logId) $intExt) } catch {}
        }
    }

    # parse messages
    $events = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($f in $resp) {
        if ($f.F -eq 1 -and $f.W -eq 2) {
            $mf = Decode-Proto $f.V
            $method = Get-PStr $mf 1
            $mp = Get-PField $mf 2
            if (-not $method -or -not ($mp -is [byte[]])) { continue }

            $evt = @{ Method = $method; RawPayload = $mp }

            switch ($method) {
                "WebcastChatMessage" {
                    $pf = Decode-Proto $mp
                    $evt.User = if ((Get-PField $pf 2) -is [byte[]]) { Read-TikTokUser (Get-PField $pf 2) } else { $null }
                    $evt.Content = Get-PStr $pf 3
                }
                "WebcastGiftMessage" {
                    $pf = Decode-Proto $mp
                    $evt.User = if ((Get-PField $pf 7) -is [byte[]]) { Read-TikTokUser (Get-PField $pf 7) } else { $null }
                    $evt.RepeatCount = Get-PField $pf 5
                    $evt.ComboCount = Get-PField $pf 8
                    $evt.IsCombo = ((Get-PField $pf 9) -eq 1)
                    $evt.GiftId = Get-PField $pf 1
                    $gb = Get-PField $pf 15
                    if ($gb -is [byte[]]) {
                        $gf = Decode-Proto $gb
                        $evt.GiftName = Get-PStr $gf 16
                        $evt.DiamondCount = Get-PField $gf 12
                    }
                }
                "WebcastLikeMessage" {
                    $pf = Decode-Proto $mp
                    $evt.User = if ((Get-PField $pf 5) -is [byte[]]) { Read-TikTokUser (Get-PField $pf 5) } else { $null }
                    $evt.TotalLikes = Get-PField $pf 3
                    $evt.Count = Get-PField $pf 1
                }
                "WebcastMemberMessage" {
                    $pf = Decode-Proto $mp
                    $evt.User = if ((Get-PField $pf 2) -is [byte[]]) { Read-TikTokUser (Get-PField $pf 2) } else { $null }
                    $evt.Action = Get-PField $pf 10
                }
                "WebcastSocialMessage" {
                    $pf = Decode-Proto $mp
                    $evt.User = if ((Get-PField $pf 2) -is [byte[]]) { Read-TikTokUser (Get-PField $pf 2) } else { $null }
                    $evt.Action = Get-PField $pf 4
                }
                "WebcastRoomUserSeqMessage" {
                    $pf = Decode-Proto $mp
                    $evt.ViewerCount = Get-PField $pf 3
                }
                "WebcastControlMessage" {
                    $pf = Decode-Proto $mp
                    $evt.Action = Get-PField $pf 2
                }
            }

            $events.Add($evt)

            # sub-routed convenience events (both raw + convenience fire)
            switch ($method) {
                "WebcastSocialMessage" {
                    $act = $evt.Action
                    if ($act -eq 1) {
                        $events.Add(@{ Method = "Follow"; User = $evt.User; RawPayload = $mp })
                    } elseif ($act -eq 3 -or $act -eq 4) {
                        $events.Add(@{ Method = "Share"; User = $evt.User; RawPayload = $mp })
                    }
                }
                "WebcastMemberMessage" {
                    if ($evt.Action -eq 1) {
                        $events.Add(@{ Method = "Join"; User = $evt.User; RawPayload = $mp })
                    }
                }
                "WebcastControlMessage" {
                    if ($evt.Action -eq 3) {
                        $events.Add(@{ Method = "LiveEnded"; RawPayload = $mp })
                    }
                }
            }
        }
    }
    return ,$events.ToArray()
}

function Close-TikTokLive {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        [hashtable]$Connection
    )
    if ($Connection.RawMode) {
        try { $Connection.Ssl.Dispose() } catch {}
        try { $Connection.Tcp.Dispose() } catch {}
    } else {
        if ($Connection.Ws.State -eq [System.Net.WebSockets.WebSocketState]::Open) {
            try { $Connection.Ws.CloseAsync([System.Net.WebSockets.WebSocketCloseStatus]::NormalClosure, "",
                [System.Threading.CancellationToken]::None).GetAwaiter().GetResult() | Out-Null } catch {}
        }
        $Connection.Ws.Dispose()
    }
}