packages feed

discord-haskell-voice-3.0.0: src/Discord/Internal/Voice/WebsocketLoop.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE DerivingStrategies #-}
{-|
Module      : Discord.Internal.Voice.WebsocketLoop
Description : Strictly for internal use only. See Discord.Voice for the public interface.
Copyright   : (c) 2021-2022 Yuto Takano
              (c) 2025-PRESENT discord-haskell-voice Contributors
License     : MIT
Maintainer  : Yuto Takano <moa17stock@gmail.com>

= WARNING

This module is considered __internal__.

The Package Versioning Policy __does not apply__.

The contents of this module may change __in any way whatsoever__ and __without__
__any warning__ between minor versions of this package, unless the identifier is
re-exported from a non-internal module.

= Description

This module provides 'launchWebsocket', a function used to start a websocket
connection to the Discord voice gateway (i.e. the voice control-plane), and
perform necessary setup including launching a heartbeat thread, selecting
encryption modes, and initiating IP Discovery. The function will also set up
the UDP socket (i.e. the voice data-plane) for voice data transmission by
calling 'launchUdp'.
-}
module Discord.Internal.Voice.WebsocketLoop
    ( launchWebsocket
    ) where

import Control.Concurrent.Async ( race )
import Control.Concurrent
    ( Chan
    , newChan
    , writeChan
    , readChan
    , threadDelay
    , forkIO
    , killThread
    , MVar
    , putMVar
    , newEmptyMVar
    , ThreadId
    , myThreadId
    , mkWeakThreadId
    , modifyMVar_
    , newMVar
    , readMVar
    )
import Control.Exception.Safe ( try, SomeException, finally )
import Lens.Micro
import Control.Monad ( forever, guard )
import Control.Monad.Except ( runExceptT, ExceptT (ExceptT) )
import Control.Monad.IO.Class ( liftIO )
import Data.Aeson ( encode, eitherDecode )
import Data.ByteString.Lazy qualified as BL
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Time.Clock.POSIX
import Data.Time
import Data.Word ( Word16 )
import Network.WebSockets
    ( ConnectionException(..)
    , Connection
    , receiveData
    , sendTextData
    )
import Wuss ( runSecureClient )

import Discord.Internal.Types.VoiceCommon
import Discord.Internal.Types.VoiceWebsocket
import Discord.Internal.Types.VoiceUDP
import Discord.Internal.Voice.CommonUtils
import Discord.Internal.Voice.UDPLoop

-- | States of the Websocket thread state machine.
data WSState
    = WSStart
    | WSClosed
    | WSResume
    deriving stock Show

-- | A custom logging function that writes the date/time and the thread ID.
(✍) :: Chan T.Text -> T.Text -> IO ()
logChan ✍ log = do
    t <- formatTime defaultTimeLocale "%F %T %q" <$> getCurrentTime
    tid <- myThreadId
    writeChan logChan $ T.pack t <> " " <> tshow tid <> " " <> log

-- | A variant of (✍) that prepends the wsError text.
(✍!) :: Chan T.Text -> T.Text -> IO ()
logChan ✍! log = logChan ✍ ("!!! Voice Websocket Error - " <> log)

-- | @connect@ is an alias for running a websocket connection using the Discord
-- endpoint URL (which contains the port as well). It makes sure to connect to
-- the correct voice gateway version (v4) as well, as the default version of 1
-- is severely out of date (the opcode behaviours are not according to docs).
connect :: T.Text -> (Connection -> IO a) -> IO a
connect endpoint = runSecureClient url port "/?v=4"
  where
    url = (T.unpack . T.takeWhile (/= ':')) endpoint
    port = (read . T.unpack . T.takeWhileEnd (/= ':')) endpoint

-- | Attempt to connect (and reconnect on disconnects) to the voice websocket.
-- Also launches the UDP thread after the initialisation.
launchWebsocket :: WebsocketLaunchOpts -> Chan T.Text -> IO ()
launchWebsocket opts log = do
    -- Keep an MVar (only for use in this function), to store the UDP launch
    -- options across Resume events.
    udpOpts <- newMVar undefined
    websocketFsm WSStart 0 udpOpts
  where
    websocketFsm :: WSState -> Int -> MVar UDPLaunchOpts -> IO ()
    -- Websocket closed legitimately. The UDP thread and this thread
    -- will be closed by the cleanup in 'runVoice'.
    websocketFsm WSClosed _retries _udpInfo = pure ()

    -- First time. Let's open a Websocket connection to the Voice gateway, do
    -- the initial Websocket handshake routine, then ask to open the UDP data
    -- plane connection. When creating the UDP thread, we will fill in the MVars
    -- in @opts@ to report back to 'runVoice', so it can be killed from the main
    -- thread if necessary (e.g. on leaving the call).
    websocketFsm WSStart _retries udpInfo = do
        next <- try $ connect (opts ^. endpoint) $ \conn -> do
            (libSends, sendTid) <- flip (setupSendLoop conn) log $ opts ^. wsHandle . _2

            result <- flip finally (killThread sendTid) $ runExceptT $ do
                helloPacket <- ExceptT $
                    over _Left ((<> "Failed to get Opcode 8 Hello: ") . tshow) <$>
                    getPayload conn

                interval <- ExceptT $ pure $
                    maybeToRight ("First packet not Opcode 8 Hello: " <> tshow helloPacket) $
                        helloPacket ^? _Hello

                -- Create a thread to add heartbeating packets to the
                -- libSends Chan.
                heartGenTid <- liftIO $ forkIO $ heartbeatLoop libSends interval log

                flip finally (liftIO $ killThread heartGenTid) $ do
                    -- Perform the Identify/Ready handshake
                    readyPacket <- ExceptT $
                        over _Left ((<> "Failed to get Opcode 2 Ready: ") . tshow) <$>
                        performIdentification conn opts

                    p <- ExceptT $ pure $
                        maybeToRight ("First packet after Identify not " <> "Opcode 2 Ready " <> tshow readyPacket) $
                            readyPacket ^? _Ready

                    secretKey <- liftIO newEmptyMVar
                    let udpLaunchOpts = UDPLaunchOpts
                            { uDPLaunchOptsSsrc      = readyPayloadSSRC p
                            , uDPLaunchOptsIp        = readyPayloadIP p
                            , uDPLaunchOptsPort      = readyPayloadPort p
                            , uDPLaunchOptsMode      = "xsalsa20_poly1305"
                            , uDPLaunchOptsUdpHandle = opts ^. udpHandle
                            , uDPLaunchOptsSecretKey = secretKey
                            -- TODO: support all encryption modes
                            }
                    -- We should be putting SSRC into the MVar to report back to
                    -- the websocket (TODO: why was this again), but we hold it off
                    -- until the ssrcCheck guard a few lines below.
                    liftIO $ modifyMVar_ udpInfo (pure . const udpLaunchOpts)

                    -- Launch the UDP thread, automatically perform 
                    -- IP discovery, which will write the result
                    -- to the receiving Chan. We will pass not the MVar but
                    -- the raw options, since there's no writing to be done.

                    forkedId <- liftIO $ forkIO $ launchUdp udpLaunchOpts log
                    flip finally (liftIO $ killThread forkedId) $ do
                        udpTidWeak <- liftIO $ mkWeakThreadId forkedId
                        liftIO $ putMVar (opts ^. udpTid) udpTidWeak

                        ipDiscovery <- liftIO $ readChan $ opts ^. udpHandle . _1
                        (ssrcCheck, ip, port) <- ExceptT $ pure $
                            maybeToRight ("First UDP Packet not IP Discovery " <> tshow ipDiscovery) $
                                ipDiscovery ^? _IPDiscovery

                        guard (ssrcCheck == udpLaunchOpts ^. ssrc)
                        liftIO $ putMVar (opts ^. ssrc) ssrcCheck

                        -- TODO: currently, we await the Opcode 4 SD right after
                        -- Select Protocol, blocking the start of heartbeats until
                        -- eventStream. This means there's a delay, so TODO to check
                        -- if this delay causes any problems. If it does, keep the
                        -- sending here, but receive the SD event in eventStream.
                        sessionDescPacket <- ExceptT $
                            over _Left ((<> "Failed to get Opcode 4 SD: ") . tshow) <$>
                                sendSelectProtocol conn ip port (udpLaunchOpts ^. mode)

                        (modeCheck, key) <- ExceptT $ pure $
                            maybeToRight ("First packet after Select Protocol " <>
                                "not Opcode 4 Session Description " <>
                                tshow readyPacket) $
                                    sessionDescPacket ^? _SessionDescription

                        guard (modeCheck == udpLaunchOpts ^. mode)

                        liftIO $ putMVar secretKey key

                        -- Move to eternal websocket event loop, mainly for the
                        -- heartbeats, but also for any user-generated packets.
                        liftIO $ eventStream conn opts interval udpLaunchOpts libSends log

            case result of
                Left reason -> log ✍! reason >> pure WSClosed
                Right state -> pure state

        -- Connection is now closed.
        case next :: Either SomeException WSState of
            Left e -> do
                (✍!) log $
                    "connection terminated due to a synchronous exception: " <>
                    tshow e
                writeChan (opts ^. wsHandle . _1) $ Left $
                    VoiceWebsocketCouldNotConnect
                        "connection terminated due to a synchronous exception"
                websocketFsm WSClosed 0 udpInfo
            Right n -> websocketFsm n 0 udpInfo

    websocketFsm WSResume retries udpInfo = do
        next <- try $ connect (opts ^. endpoint) $ \conn -> do
            (libSends, sendTid) <- flip (setupSendLoop conn) log $ opts ^. wsHandle . _2
            helloPacket <- getPayload conn
            case helloPacket of
                Left e -> do
                    (✍!) log $ "Failed to get Opcode 8 Hello: " <> tshow e
                    pure WSClosed
                Right (Hello interval) -> do
                    -- Create a thread to add heartbeating packets to the
                    -- libSends Chan.
                    heartGenTid <- forkIO $ heartbeatLoop libSends interval log
                    -- Perform the Resume/Resumed handshake
                    resumedPacket <- performResumption conn opts
                    case resumedPacket of
                        Left e -> do
                            (✍!) log $ "Failed to get Opcode 9 Resumed: " <> tshow e
                            pure WSClosed
                        Right Discord.Internal.Types.VoiceWebsocket.Resumed -> do
                            -- use the previous UDP launch options since it's not resent
                            udpLaunchOpts <- readMVar udpInfo

                            -- Pass not the MVar but the raw options, since
                            -- there's no writing to be done.
                            finally (eventStream conn opts interval udpLaunchOpts libSends log)
                                (killThread heartGenTid >> killThread sendTid)
                        Right p -> do
                            (✍!) log $ "First packet after Resume not " <>
                                "Opcode 9 Resumed: " <> tshow p
                            pure WSClosed
                Right p -> do
                    (✍!) log $ "First packet not Opcode 8 Hello: " <> tshow p
                    pure WSClosed

        case next :: Either SomeException WSState of
            Left e -> do
                (✍!) log $ "could not resume due to a synchronous exception: " <>
                    tshow e <> ", retrying after 5 seconds"
                threadDelay $ 5 * (10^(6 :: Int))
                websocketFsm WSResume (retries + 1) udpInfo
            Right n -> websocketFsm n retries udpInfo

-- | @setupSendLoop@ takes a 'Chan' of user-generated packets to send in the
-- websocket. It forks a thread that runs 'sendableLoop' using the provided
-- Chan, together with a new internal-use-only Chan for e.g. heartbeat packets.
setupSendLoop
    :: Connection
    -- ^ The websocket connection
    -> VoiceWebsocketSendChan
    -- ^ User generated packets to send in the Websocket
    -> Chan T.Text
    -- ^ Logging channel
    -> IO (VoiceWebsocketSendChan, ThreadId)
    -- ^ Chan to send internal-only packets in the Websocket, and the thread
    -- ID of the eternal sending thread (useful for killing it).
setupSendLoop conn userSends log = do
    -- The following Chan will be used for accumulating library-generated
    -- WebSocket messages that we need to send to Discord, mostly for heartbeats.
    libSends <- newChan
    -- Start said eternal sending fork, which will eternally send from library-
    -- generated and user-generated packets.
    sendLoopId <- forkIO $ sendableLoop conn libSends userSends log

    pure (libSends, sendLoopId)

-- | @performIdentification@ sends the voice gateway uplink Opcode 0 Identify
-- packet to Discord, and awaits the voice gateway downlink Opcode 2 Ready,
-- which has the UDP connection info in its payload.
performIdentification
    :: Connection
    -- ^ The websocket connection
    -> WebsocketLaunchOpts
    -- ^ The options used to launch the websocket
    -> IO (Either ConnectionException VoiceWebsocketReceivable)
performIdentification conn opts = do
    -- Send opcode 0 Identify
    sendTextData conn $ encode $ Identify $ IdentifyPayload
        { identifyPayloadServerId = opts ^. guildId
        , identifyPayloadUserId = opts ^. botUserId
        , identifyPayloadSessionId = opts ^. sessionId
        , identifyPayloadToken = opts ^. token
        }

    getPayload conn

-- | @performResumption@ sends the voice gateway uplink Opcode 7 Resume packet
-- to Discord, and awaits the voice gateway downlink Opcode 9 Resumed payload.
performResumption
    :: Connection
    -- ^ The websocket connection
    -> WebsocketLaunchOpts
    -- ^ The options used to launch the websocket
    -> IO (Either ConnectionException VoiceWebsocketReceivable)
performResumption conn opts = do
    -- Send opcode 7 Resume
    sendTextData conn $ encode $
        Resume (opts ^. guildId) (opts ^. sessionId) (opts ^. token)

    getPayload conn

-- | @sendSelectProtocol@ sends the voice gateway uplink Opcode 1 Select
-- Protocol to Discord., and waits until we get voice gatway downlink Opcode 4
-- Session Description. We ignore any irrelevant packets during this wait,
-- including downlink Opcode 11 Client Connect, downlink Opcode 18 Client Flags,
-- downlink 20 Client Platform, all of which are sent to us if there is already
-- another user in the voice chat.
sendSelectProtocol
    :: Connection
    -- ^ The websocket connection
    -> T.Text
    -- ^ Our local UDP thread IP as found by IP Discovery
    -> Integer
    -- ^ Our local UDP thread port as found by IP Discovery
    -> T.Text
    -- ^ Selected encryption mode
    -> IO (Either ConnectionException VoiceWebsocketReceivable)
sendSelectProtocol conn ip port mode = do
    sendTextData conn $ encode $ SelectProtocol $ 
        SelectProtocolPayload "udp" ip port mode

    -- Skip payloads until we get the Opcode 4 Session Description.
    waitUntilSessionDescription
  where
    waitUntilSessionDescription :: IO (Either ConnectionException VoiceWebsocketReceivable)
    waitUntilSessionDescription = do
        payload <- getPayload conn
        case payload of
            Left e -> pure $ Left e
            Right s@(SessionDescription _ _) -> pure $ Right s
            Right _ -> waitUntilSessionDescription

-- | @getPayload@ gets one packet from the Websocket 'Connection' using
-- 'receiveData', parsing it into a 'VoiceWebsocketReceivable' using Aeson. If
-- the packet could not be parsed, it will return @Right (ParseError info)@.
getPayload
    :: Connection
    -> IO (Either ConnectionException VoiceWebsocketReceivable)
getPayload conn = try $ do
    msg' <- receiveData conn
    case eitherDecode msg' of
        Right msg -> pure msg
        Left err  -> pure $ ParseError $ T.pack err
            <> " while decoding " <> TE.decodeUtf8 (BL.toStrict msg')

-- | @sendableLoop@ eternally send data from @libSends@ and @usrSends@ channels,
-- whenever either of them have data to send.
sendableLoop
    :: Connection
    -- ^ The websocket connection
    -> VoiceWebsocketSendChan
    -- ^ Internal-use sendable channel, e.g. for heartbeat packets
    -> VoiceWebsocketSendChan
    -- ^ User-generated sendable packets channel
    -> Chan T.Text
    -- ^ Logs
    -> IO ()
sendableLoop conn libSends usrSends log = do
    -- Wait-time taken from discord-haskell/Internal.Gateway.EventLoop
    threadDelay $ round ((10^(6 :: Int)) * (62 / 120) :: Double)
    -- Get whichever possible, and send it
    payload <- either id id <$> race (readChan libSends) (readChan usrSends)
    -- log ✍ ("(send) " <> tshow payload) -- TODO: debug, remove.
    sendTextData conn $ encode payload
    sendableLoop conn libSends usrSends log

-- | @heartbeatLoop@ eternally generates Heartbeat packets every interval, and
-- puts it into the sendable channel to be sent by 'sendableLoop'.
heartbeatLoop
    :: VoiceWebsocketSendChan
    -- ^ The internal-use sendable channel for websocket packets
    -> Int
    -- ^ milliseconds
    -> Chan T.Text
    -- ^ Logs
    -> IO ()
heartbeatLoop libSends interval _log = do
    threadDelay $ 1 * 10^(6 :: Int)
    forever $ do
        time <- round <$> getPOSIXTime
        writeChan libSends $ Heartbeat time
        threadDelay $ interval * 1000

-- | @eventStream@ is the main event loop for the Websocket, after all initial
-- handshake stages (Hello and identification/resumption). It will continuously
-- read the top packet in the Websocket receives, and handle closures, and
-- packet responses (like heartbeat responses).
eventStream
    :: Connection
    -- ^ The websocket connection
    -> WebsocketLaunchOpts
    -- ^ Options used to launch the websokcet connection
    -> Int
    -- ^ Interval at which we expect to receive a heartbeat response. We tolerate
    -- up to double of the interval, but if no heartbeat response is received in
    -- this time, we consider the connection to be lost.
    -> UDPLaunchOpts
    -- ^ Options used to launch the UDP data plane
    -> VoiceWebsocketSendChan
    -- ^ The internal-use sendable channel for websocket packets
    -> Chan T.Text
    -- ^ Logs
    -> IO WSState
eventStream conn opts interval udpLaunchOpts libSends log = do
    -- there has to be at least one packet every @interval@ milliseconds (which
    -- is the heartbeat response), so if we don't get that, it's a sign of
    -- the connection gone, we should reconnect. For a quick heuristic accounting
    -- for any network delays, allow for a tolerance of double the time.
    payload <- timeoutMs (interval * 2) $ getPayload conn
    -- log ✍ ("(recv) " <> tshow payload) -- TODO: debug, remove.
    case payload of
        Nothing -> do
            log ✍! "connection timed out, trying to reconnect again."
            pure WSResume
        -- Network-WebSockets, type ConnectionException
        Just (Left (CloseRequest code str)) -> do
            -- Whether we resume or gracefully close depends on the close code,
            -- so offload the decision to the close code handler.
            handleClose code str
        Just (Left _) -> do
            log ✍! "connection exception in eventStream, trying to reconnect."
            pure WSResume
        Just (Right (HeartbeatAck _)) ->
            eventStream conn opts interval udpLaunchOpts libSends log
        Just (Right receivable) -> do
            writeChan (opts ^. wsHandle . _1) (Right receivable)
            eventStream conn opts interval udpLaunchOpts libSends log

  where
    -- | Handle Websocket Close codes by logging appropriate messages and
    -- closing the connection.
    handleClose :: Word16 -> BL.ByteString -> IO WSState
    handleClose 1000 _str = log ✍! "websocket closed normally."
        >> pure WSClosed
    handleClose 4001 _str = log ✍! "websocket closed due to unknown opcode"
        >> pure WSClosed
    handleClose 4014 _str = log ✍! ("vc deleted, main gateway closed, or bot " <>
        "forcefully disconnected... Restarting voice.")
        >> pure WSStart
    handleClose 4015 _str = log ✍! "server crashed on Discord side, resuming"
        >> pure WSResume
    handleClose code str = (✍!) log ("connection closed with code: [" <>
        tshow code <> "] " <> TE.decodeUtf8 (BL.toStrict str))
        >> pure WSClosed