From 3ae46f1cb00c5aa06086a9171652d45e9cca04bf Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 11 Mar 2025 16:48:36 +0000 Subject: [PATCH 001/121] WIP --- src/Discord/Voice/VoiceClient.php | 481 +++++++++++++++++++----------- 1 file changed, 304 insertions(+), 177 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 85a112afc..f3df0c046 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -11,29 +11,36 @@ namespace Discord\Voice; -use Discord\Exceptions\FFmpegNotFoundException; -use Discord\Exceptions\FileNotFoundException; -use Discord\Exceptions\LibSodiumNotFoundException; -use Discord\Exceptions\OutdatedDCAException; -use Discord\Helpers\Buffer as RealBuffer; -use Discord\Helpers\Collection; -use Discord\Parts\Channel\Channel; +use Throwable; use Discord\WebSockets\Op; -use Evenement\EventEmitter; -use Ratchet\Client\Connector as WsFactory; -use Ratchet\Client\WebSocket; -use React\Datagram\Factory as DatagramFactory; use React\Datagram\Socket; -use React\Dns\Resolver\Factory as DNSFactory; -use React\EventLoop\LoopInterface; +use Evenement\EventEmitter; +use React\Promise\Deferred; use Psr\Log\LoggerInterface; +use React\Dns\Config\Config; +use Ratchet\Client\WebSocket; +use Discord\Parts\User\Member; +use Discord\Helpers\Collection; use React\ChildProcess\Process; -use React\Promise\Deferred; -use React\Promise\PromiseInterface; -use React\Stream\ReadableResourceStream as Stream; +use Discord\Parts\Channel\Channel; +use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; +use React\Promise\PromiseInterface; use React\Stream\ReadableResourceStream; +use Discord\Helpers\Buffer as RealBuffer; use React\Stream\ReadableStreamInterface; +use Ratchet\Client\Connector as WsFactory; +use Discord\Exceptions\OutdatedDCAException; +use Discord\Exceptions\FileNotFoundException; +use React\Dns\Resolver\Factory as DNSFactory; +use React\Datagram\Factory as DatagramFactory; +use Discord\Exceptions\FFmpegNotFoundException; +use Discord\Exceptions\LibSodiumNotFoundException; +use Discord\Exceptions\Voice\AudioAlreadyPlayingException; +use Discord\Exceptions\Voice\ClientNotReadyException; +use React\Stream\ReadableResourceStream as Stream; +use Discord\Voice\VoicePacket; +use Ratchet\RFC6455\Messaging\Message; /** * The Discord voice client. @@ -54,28 +61,28 @@ class VoiceClient extends EventEmitter * * @var string The silence frame. */ - public const SILENCE_FRAME = "\xF8\xFF\xFE"; + public const SILENCE_FRAME = "\0xF8\0xFF\0xFE"; /** * Is the voice client ready? * * @var bool Whether the voice client is ready. */ - protected $ready = false; + protected bool $ready = false; /** * The DCA binary name that we will use. * * @var string The DCA binary name that will be run. */ - protected $dca; + protected ?string $dca; /** * The FFmpeg binary location. * * @var string */ - protected $ffmpeg; + protected ?string $ffmpeg; /** * The ReactPHP event loop. @@ -89,68 +96,68 @@ class VoiceClient extends EventEmitter * * @var WebSocket The main WebSocket client. */ - protected $mainWebsocket; + protected ?WebSocket $mainWebsocket; /** * The voice WebSocket instance. * * @var WebSocket The voice WebSocket client. */ - protected $voiceWebsocket; + protected ?WebSocket $voiceWebsocket; /** * The UDP client. * * @var Socket The voiceUDP client. */ - public $client; + public ?Socket $client; /** * The Channel that we are connecting to. * * @var Channel The channel that we are going to connect to. */ - protected $channel; + protected ?Channel $channel; /** * Data from the main WebSocket. * * @var array Information required for the voice WebSocket. */ - protected $data; + protected ?array $data; /** * The Voice WebSocket endpoint. * * @var string The endpoint the Voice WebSocket and UDP client will connect to. */ - protected $endpoint; + protected ?string $endpoint; /** * The port the UDP client will use. * * @var int The port that the UDP client will connect to. */ - protected $udpPort; + protected ?int $udpPort; /** * The UDP heartbeat interval. * * @var int How often we send a heartbeat packet. */ - protected $heartbeat_interval; + protected ?int $heartbeatInterval; /** * The Voice WebSocket heartbeat timer. * - * @var TimerInterface The heartbeat periodic timer. + * @var TimerInterface|null The heartbeat periodic timer. */ protected $heartbeat; /** * The UDP heartbeat timer. * - * @var TimerInterface The heartbeat periodic timer. + * @var TimerInterface|null The heartbeat periodic timer. */ protected $udpHeartbeat; @@ -159,110 +166,118 @@ class VoiceClient extends EventEmitter * * @var int The heartbeat sequence. */ - protected $heartbeatSeq = 0; + protected int $heartbeatSeq = 0; /** * The SSRC value. * * @var int The SSRC value used for RTP. */ - public $ssrc; + public ?int $ssrc; /** * The sequence of audio packets being sent. * * @var int The sequence of audio packets. */ - protected $seq = 0; + protected int $seq = 0; /** * The timestamp of the last packet. * * @var int The timestamp the last packet was constructed. */ - protected $timestamp = 0; + protected int $timestamp = 0; /** * The Voice WebSocket mode. * + * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes * @var string The voice mode. */ - protected $mode = 'xsalsa20_poly1305'; + protected string $mode = 'aead_xchacha20_poly1305_rtpsize'; /** * The secret key used for encrypting voice. * * @var string The secret key. */ - protected $secret_key; + protected ?string $secretKey; + + /** + * The raw secret key + * + * @var array + */ + protected ?array $rawKey; /** * Are we currently set as speaking? * * @var bool Whether we are speaking or not. */ - protected $speaking = false; + protected bool $speaking = false; /** * Whether we are set as mute. * * @var bool Whether we are set as mute. */ - protected $mute = false; + protected bool $mute = false; /** * Whether we are set as deaf. * * @var bool Whether we are set as deaf. */ - protected $deaf = false; + protected bool $deaf = false; /** * Whether the voice client is currently paused. * * @var bool Whether the voice client is currently paused. */ - protected $paused = false; + protected bool $paused = false; /** * Have we sent the login frame yet? * * @var bool Whether we have sent the login frame. */ - protected $sentLoginFrame = false; + protected bool $sentLoginFrame = false; /** * The time we started sending packets. * * @var int The time we started sending packets. */ - protected $startTime; + protected ?int $startTime; /** * The stream time of the last packet. * * @var int The time we sent the last packet. */ - protected $streamTime = 0; + protected int $streamTime = 0; /** * The size of audio frames, in milliseconds. * * @var int The size of audio frames. */ - protected $frameSize = 20; + protected int $frameSize = 20; /** * Collection of the status of people speaking. * - * @var CollectionInterface Status of people speaking. + * @var null|CollectionInterface Status of people speaking. */ protected $speakingStatus; /** * Collection of voice decoders. * - * @var CollectionInterface Voice decoders. + * @var null|CollectionInterface Voice decoders. */ protected $voiceDecoders; @@ -271,14 +286,14 @@ class VoiceClient extends EventEmitter * * @var array Voice audio recieve streams. */ - protected $recieveStreams; + protected ?array $recieveStreams; /** * The volume the audio will be encoded with. * * @var int The volume that the audio will be encoded in. */ - protected $volume = 100; + protected int $volume = 100; /** * The audio application to encode with. @@ -287,33 +302,33 @@ class VoiceClient extends EventEmitter * * @var string The audio application. */ - protected $audioApplication = 'audio'; + protected string $audioApplication = 'audio'; /** * The bitrate to encode with. * * @var int Encoding bitrate. */ - protected $bitrate = 128000; + protected int $bitrate = 128000; /** * Is the voice client reconnecting? * * @var bool Whether the voice client is reconnecting. */ - protected $reconnecting = false; + protected bool $reconnecting = false; /** * Is the voice client being closed by user? * * @var bool Whether the voice client is being closed by user. */ - protected $userClose = false; + protected bool $userClose = false; /** * The logger. * - * @var LoggerInterface Logger. + * @var LoggerInterface|null Logger. */ protected $logger; @@ -322,21 +337,21 @@ class VoiceClient extends EventEmitter * * @var int Voice version. */ - protected $version = 4; + protected int $version = 8; /** * The Config for DNS Resolver. * * @var string|\React\Dns\Config\Config */ - protected $dnsConfig; + protected null|string|Config $dnsConfig; /** * Silence Frame Remain Count. * * @var int Amount of silence frames remaining. */ - protected $silenceRemaining = 5; + protected int $silenceRemaining = 5; /** * readopus Timer. @@ -350,7 +365,14 @@ class VoiceClient extends EventEmitter * * @var RealBuffer The Audio Buffer */ - protected $buffer; + protected ?RealBuffer $buffer; + + /** + * Current clients connected to the voice chat + * + * @var array + */ + public array $clientsConnected = []; /** * Constructs the Voice Client instance. @@ -421,113 +443,36 @@ public function handleWebSocketConnection(WebSocket $ws): void $firstPack = true; $ip = $port = ''; - $discoverUdp = function ($message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port) { - $data = json_decode($message->getPayload()); - - if ($data->op == Op::VOICE_READY) { - $ws->removeListener('message', $discoverUdp); - - $this->udpPort = $data->d->port; - $this->ssrc = $data->d->ssrc; - - $this->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); - - $buffer = new Buffer(74); - $buffer[1] = "\x01"; - $buffer[3] = "\x46"; - $buffer->writeUInt32BE($this->ssrc, 4); - /** @var PromiseInterface */ - $promise = $udpfac->createClient("{$data->d->ip}:{$this->udpPort}"); - - $promise->then(function (Socket $client) use (&$ws, &$firstPack, &$ip, &$port, $buffer) { - $this->logger->debug('connected to voice UDP'); - $this->client = $client; - - $this->loop->addTimer(0.1, function () use (&$client, $buffer) { - $client->send((string) $buffer); - }); - - $this->udpHeartbeat = $this->loop->addPeriodicTimer(5, function () use ($client) { - $buffer = new Buffer(9); - $buffer[0] = "\xC9"; - $buffer->writeUInt64LE($this->heartbeatSeq, 1); - ++$this->heartbeatSeq; - - $client->send((string) $buffer); - $this->emit('udp-heartbeat', []); - }); - - $client->on('error', function ($e) { - $this->emit('udp-error', [$e]); - }); - - $decodeUDP = function ($message) use (&$decodeUDP, $client, &$ip, &$port) { - $message = (string) $message; - // let's get our IP - $ip_start = 8; - $ip = substr($message, $ip_start); - $ip_end = strpos($ip, "\x00"); - $ip = substr($ip, 0, $ip_end); - - // now the port! - $port = substr($message, strlen($message) - 2); - $port = unpack('v', $port)[1]; - - $this->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); - - $payload = [ - 'op' => Op::VOICE_SELECT_PROTO, - 'd' => [ - 'protocol' => 'udp', - 'data' => [ - 'address' => $ip, - 'port' => (int) $port, - 'mode' => $this->mode, - ], - ], - ]; - - $this->send($payload); - - $client->removeListener('message', $decodeUDP); + $ws->on('message', function (Message $message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port): void { + if ($message?->getPayload() === null || json_decode($message->getPayload() ?? '') === null) { - if (! $this->deaf) { - $client->on('message', [$this, 'handleAudioData']); - } - }; - - $client->on('message', $decodeUDP); - }, function ($e) { - $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); - $this->emit('error', [$e]); - }); + dd($message); + return; } - }; - $ws->on('message', $discoverUdp); - $ws->on('message', function ($message) { + $data = json_decode($message->getPayload()); + echo 'Received: ' . json_encode($data) . PHP_EOL; + $this->emit('ws-message', [$message, $this]); switch ($data->op) { case Op::VOICE_HEARTBEAT_ACK: // keepalive response $end = microtime(true); - $start = $data->d; + $start = $data->d->t; $diff = ($end - $start) * 1000; $this->logger->debug('received heartbeat ack', ['response_time' => $diff]); $this->emit('ws-ping', [$diff]); - $this->emit('ws-heartbeat-ack', [$data->d]); + $this->emit('ws-heartbeat-ack', [$data->d->t]); break; case Op::VOICE_DESCRIPTION: // ready $this->ready = true; $this->mode = $data->d->mode; - $this->secret_key = ''; - - foreach ($data->d->secret_key as $part) { - $this->secret_key .= pack('C*', $part); - } + $this->secretKey = ''; + $this->rawKey = $data->d->secret_key; + $this->secretKey = implode('', array_map(fn ($value) => pack('C', $value), $this->rawKey)); $this->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); @@ -539,25 +484,40 @@ public function handleWebSocketConnection(WebSocket $ws): void } break; - case Op::VOICE_SPEAKING: // user started speaking + case Op::VOICE_SPEAKING: // currently connected users $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); $this->speakingStatus[$data->d->ssrc] = $data->d; break; case Op::VOICE_HELLO: - $this->heartbeat_interval = $data->d->heartbeat_interval; + $this->heartbeatInterval = $data->d->heartbeat_interval; $sendHeartbeat = function () { $this->send([ 'op' => Op::VOICE_HEARTBEAT, - 'd' => (int) microtime(true), + 'd' => [ + 't' => (int) microtime(true), + 'seq_ack' => 10 + ] ]); $this->logger->debug('sending heartbeat'); $this->emit('ws-heartbeat', []); }; $sendHeartbeat(); - $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeat_interval / 1000, $sendHeartbeat); + $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, $sendHeartbeat); + break; + case Op::VOICE_CLIENT_CONNECT: + # "d" contains an array with ['user_ids' => array] + $this->clientsConnected[] = $data->d->user_ids; + break; + case Op::VOICE_CLIENT_UNKNOWN_15: + case Op::VOICE_CLIENT_UNKNOWN_18: + $this->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + break; + case Op::VOICE_CLIENT_PLATFORM: + # handlePlatformPerUser + # platform = 0 assumed to be Desktop break; case Op::VOICE_DAVE_PREPARE_TRANSITION: $this->handleDavePrepareTransition($data); @@ -592,10 +552,104 @@ public function handleWebSocketConnection(WebSocket $ws): void case Op::VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME: $this->handleDaveMlsInvalidCommitWelcome($data); break; + + case Op::VOICE_READY: { + $this->udpPort = $data->d->port; + $this->ssrc = $data->d->ssrc; + + $this->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + + $buffer = new Buffer(74); + $buffer[1] = "\x01"; + $buffer[3] = "\x46"; + $buffer->writeUInt32BE($this->ssrc, 4); + /** @var PromiseInterface */ + $udpfac->createClient("{$data->d->ip}:{$this->udpPort}")->then(function (Socket $client) use (&$ip, &$port, $buffer): void { + $this->logger->debug('connected to voice UDP'); + $this->client = $client; + + $this->loop->addTimer(1, function () use ($buffer) { + $this->client->send((string) $buffer); + }); + + $this->udpHeartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { + $buffer = new Buffer(9); + $buffer[0] = 0xC9; + $buffer->writeUInt64LE($this->heartbeatSeq, 1); + ++$this->heartbeatSeq; + + $this->client->send($buffer->__toString()); + $this->emit('udp-heartbeat', []); + + $this->logger->debug('sent UDP heartbeat'); + }); + + $client->on('error', function ($e): void { + $this->emit('udp-error', [$e]); + }); + + $decodeUDP = function ($message) use (&$decodeUDP, $client, &$ip, &$port): void { + + /** + * Unpacks the message into an array. + * + * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively + * n (unsigned short) | Length | 2 bytes | Length of the following data + * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender + * A64 (string) | Address | 64 bytes | The IP address of the sender + * n (unsigned short) | Port | 2 bytes | The port of the sender + * + * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery + * @see https://www.php.net/manual/en/function.unpack.php + * @see https://www.php.net/manual/en/function.pack.php For the formats + */ + $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); + + # Commented out since it's not being used as of yet + # $typeRequest = $unpackedMessageArray['Type1']; + # $typeResponse = $unpackedMessageArray['Type2']; + # $length = $unpackedMessageArray['Length']; + $this->ssrc = $unpackedMessageArray['SSRC']; + $ip = $unpackedMessageArray['Address']; + $port = $unpackedMessageArray['Port']; + + $this->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + + $this->send([ + 'op' => Op::VOICE_SELECT_PROTO, + 'd' => [ + 'protocol' => 'udp', + 'data' => [ + 'address' => $ip, + 'port' => $port, + 'mode' => $this->mode, + ], + ], + ]); + + $client->removeListener('message', $decodeUDP); + + if (! $this->deaf) { + $client->on('message', [$this, 'handleAudioData']); + } + }; + + $client->on('message', $decodeUDP); + }, function (Throwable $e): void { + $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); + $this->emit('error', [$e]); + }); + + break; + } + default: + echo 'Unknown opcode: ' . $data->op . PHP_EOL; + echo 'Data: ' . json_encode($data) . PHP_EOL; + break; } }); - $ws->on('error', function ($e) { + $ws->on('error', function ($e): void { $this->logger->error('error with voice websocket', ['e' => $e->getMessage()]); $this->emit('ws-error', [$e]); }); @@ -642,6 +696,8 @@ public function handleWebSocketClose(int $op, string $reason): void $this->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); $this->emit('ws-close', [$op, $reason, $this]); + $this->clientsConnected = []; + // Cancel heartbeat timers if (null !== $this->heartbeat) { $this->loop->cancelTimer($this->heartbeat); @@ -654,7 +710,8 @@ public function handleWebSocketClose(int $op, string $reason): void } // Close UDP socket. - if ($this->client) { + if (isset($this->client)) { + $this->logger->warning('closing UDP client'); $this->client->close(); } @@ -666,7 +723,7 @@ public function handleWebSocketClose(int $op, string $reason): void $this->logger->warning('reconnecting in 2 seconds'); // Retry connect after 2 seconds - $this->loop->addTimer(2, function () { + $this->loop->addTimer(2, function (): void { $this->reconnecting = true; $this->sentLoginFrame = false; @@ -720,21 +777,22 @@ public function handleVoiceServerChange(array $data = []): void public function playFile(string $file, int $channels = 2): PromiseInterface { $deferred = new Deferred(); + $notAValidFile = filter_var($file, FILTER_VALIDATE_URL) === false && ! file_exists($file); - if (filter_var($file, FILTER_VALIDATE_URL) === false && ! file_exists($file)) { - $deferred->reject(new FileNotFoundException("Could not find the file \"{$file}\".")); - - return $deferred->promise(); - } - - if (! $this->ready) { - $deferred->reject(new \RuntimeException('Voice Client is not ready.')); + if ( + $notAValidFile || (! $this->ready) || $this->speaking + ) { + if ($notAValidFile) { + $deferred->reject(new FileNotFoundException("Could not find the file \"{$file}\".")); + } - return $deferred->promise(); - } + if (! $this->ready) { + $deferred->reject(new ClientNotReadyException()); + } - if ($this->speaking) { - $deferred->reject(new \RuntimeException('Audio already playing.')); + if ($this->speaking) { + $deferred->reject(new AudioAlreadyPlayingException()); + } return $deferred->promise(); } @@ -1055,10 +1113,10 @@ private function sendBuffer(string $data): void return; } - $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secret_key); + $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secretKey); $this->client->send((string) $packet); - $this->streamTime = microtime(true); + $this->streamTime = (int) microtime(true); $this->emit('packet-sent', [$packet]); } @@ -1085,6 +1143,7 @@ public function setSpeaking(bool $speaking = true): void 'd' => [ 'speaking' => $speaking, 'delay' => 0, + 'ssrc' => $this->ssrc, ], ]); @@ -1324,7 +1383,7 @@ public function close(): void $this->client->close(); $this->voiceWebsocket->close(); - $this->heartbeat_interval = null; + $this->heartbeatInterval = null; if (null !== $this->heartbeat) { $this->loop->cancelTimer($this->heartbeat); @@ -1413,9 +1472,9 @@ public function handleVoiceStateUpdate(object $data): void * * @param int|string $id Either a SSRC or User ID. * - * @return RecieveStream + * @return null|RecieveStream|ReceiveStream */ - public function getRecieveStream($id): ?RecieveStream + public function getRecieveStream($id): null|RecieveStream|ReceiveStream { if (isset($this->recieveStreams[$id])) { return $this->recieveStreams[$id]; @@ -1426,6 +1485,8 @@ public function getRecieveStream($id): ?RecieveStream return $this->recieveStreams[$status->ssrc]; } } + + return null; } /** @@ -1435,16 +1496,78 @@ public function getRecieveStream($id): ?RecieveStream */ protected function handleAudioData(string $message): void { - $voicePacket = VoicePacket::make($message); - $nonce = new Buffer(24); - $nonce->write($voicePacket->getHeader(), 0); - $message = \sodium_crypto_secretbox_open($voicePacket->getData(), (string) $nonce, $this->secret_key); + # echo 'Handling audio data' . PHP_EOL; + # echo 'Message: ' . $message . PHP_EOL; + + + + ##### NON AI CODE DOWN HERE ######### + + $voicePacket = new VoicePacket(); + // $voicePacket->unpack($message); + + + // Supondo que $message contenha a mensagem completa e $this->secretKey esteja definida. + $len = strlen($message); - if ($message === false) { - // if we can't decode the message, drop it silently. + // 1. Calcular o tamanho do header (12 bytes ou 16 se o bit de extensão estiver setado) + $headerSize = 12; + $firstByte = ord($message[0]); + if (($firstByte >> 4) & 0x01) { + $headerSize += 4; + } + + // 2. Extrair o header + $header = substr($message, 0, $headerSize); + + // 3. Preparar o nonce: pegar os últimos 4 bytes e preencher à esquerda com zeros + $nonce = substr($message, $len - 4, 4); + $nonceBuffer = str_pad($nonce, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, "\0", STR_PAD_RIGHT); + + // 4. Extrair o ciphertext e a tag de autenticação + // A mensagem: [header][ciphertext][auth tag][nonce] + // O tamanho do ciphertext é: total - headerSize - 16 (auth tag) - 4 (nonce) + $encryptedLength = $len - $headerSize - 16 - 4; + $cipherText = substr($message, $headerSize, $encryptedLength); + $authTag = substr($message, $headerSize + $encryptedLength, 16); + + // Concatenar ciphertext e auth tag, como na versão JS + $combined = $cipherText . $authTag; + + try { + // 5. Decriptar a mensagem usando o sodium + $resultMessage = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + $combined, + $header, + $nonceBuffer, + $this->secretKey + ); + + if ($resultMessage === false) { + // Se a decriptação falhar, log o erro e retorne + $this->logger->error('Failed to decode voice packet.', [ + 'padded_nonce' => $nonceBuffer, + 'message_len' => $len, + ]); + return; + } + + // 6. Verificar e remover a extensão, se presente + if (substr($message, 12, 2) === "\xBE\xDE") { + // Lê os 2 bytes após o identificador da extensão para obter o tamanho da extensão + $extLengthData = substr($message, 14, 2); + $headerExtensionLength = unpack('n', $extLengthData)[1]; + // Remove 4 * headerExtensionLength bytes do início do resultado decriptado + $resultMessage = substr($resultMessage, 4 * $headerExtensionLength); + } + } catch (\Exception $e) { + $this->logger->error('Exception occurred when decoding voice packet: ' . $e->getMessage()); return; } + dd('Success!', $resultMessage); + + $message = $resultMessage; $this->emit('raw', [$message, $this]); $vp = VoicePacket::make($voicePacket->getHeader().$message); @@ -1453,6 +1576,7 @@ protected function handleAudioData(string $message): void if (null === $ss) { // for some reason we don't have a speaking status + $this->logger->warning('Received voice packet from unknown SSRC.', ['ssrc' => $vp->getSSRC()]); return; } @@ -1462,10 +1586,12 @@ protected function handleAudioData(string $message): void $this->recieveStreams[$ss->ssrc] = new RecieveStream(); $this->recieveStreams[$ss->ssrc]->on('pcm', function ($d) { + echo 'PCM: ' . $d . PHP_EOL; $this->emit('channel-pcm', [$d, $this]); }); $this->recieveStreams[$ss->ssrc]->on('opus', function ($d) { + echo 'OPUS: ' . $d . PHP_EOL; $this->emit('channel-opus', [$d, $this]); }); } @@ -1475,6 +1601,7 @@ protected function handleAudioData(string $message): void $decoder->start($this->loop); $decoder->stdout->on('data', function ($data) use ($ss) { + echo 'Data: ' . $data . PHP_EOL; $this->recieveStreams[$ss->ssrc]->writePCM($data); }); $decoder->stderr->on('data', function ($data) use ($ss) { @@ -1765,7 +1892,7 @@ public function getChannel(): Channel * * @link https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation */ - private function insertSilence(): void + public function insertSilence(): void { while (--$this->silenceRemaining > 0) { $this->sendBuffer(self::SILENCE_FRAME); From dd580e0ee07bbb5566edffabfe73beb752cf780d Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 16:51:32 +0000 Subject: [PATCH 002/121] Updates the byte version, to the latest (forked by me) one --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5ddf1afb4..45c6ef72c 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "exan/pawl": "^0.4.4", "react/datagram": "^1.8", "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0", - "trafficcophp/bytebuffer": "^0.3", + "trafficcophp/bytebuffer": "^0.4", "monolog/monolog": "^2.1.1 || ^3.0", "react/event-loop": "^1.2", "ext-json": "*", @@ -41,6 +41,12 @@ "wyrihaximus/react-cache-redis": "^4.5", "symfony/cache": "^5.4" }, + "repositories": [ + { + "type": "github", + "url": "https://github.com/alexandre433/byte-buffer" + } + ], "autoload": { "files": [ "src/Discord/functions.php" From efc034cf788965be3fa09081fc79db9ed9a2b43c Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 16:52:31 +0000 Subject: [PATCH 003/121] Adds a new class to rename the previous one "RecieveStream" to "ReceiveStream" --- src/Discord/Voice/ReceiveStream.php | 286 ++++++++++++++++++++++++++++ src/Discord/Voice/RecieveStream.php | 264 +------------------------ 2 files changed, 288 insertions(+), 262 deletions(-) create mode 100644 src/Discord/Voice/ReceiveStream.php diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php new file mode 100644 index 000000000..84be35183 --- /dev/null +++ b/src/Discord/Voice/ReceiveStream.php @@ -0,0 +1,286 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Voice; + +use Evenement\EventEmitter; +use React\Stream\DuplexStreamInterface; +use React\Stream\WritableStreamInterface; + +/** + * Handles recieving audio from Discord. + * + * @since 10.5.0 The class was renamed to ReceiveStream. + * @since 3.2.0 + */ +class ReceiveStream extends EventEmitter implements DuplexStreamInterface +{ + /** + * Contains PCM data. + * + * @var string PCM data. + */ + protected $pcmData = ''; + + /** + * Contains Opus data. + * + * @var string Opus data. + */ + protected $opusData = ''; + + /** + * Is the stream paused? + * + * @var bool Whether the stream is paused. + */ + protected $isPaused; + + /** + * Whether the stream is closed. + * + * @var bool Whether the stream is closed. + */ + protected $isClosed = false; + + /** + * The PCM pause buffer. + * + * @var array The PCM pause buffer. + */ + protected $pcmPauseBuffer = []; + + /** + * The pause buffer. + * + * @var array The pause buffer. + */ + protected $opusPauseBuffer = []; + + /** + * Constructs a stream. + */ + public function __construct() + { + // empty for now + } + + /** + * Writes PCM audio data. + * + * @param string $pcm PCM audio data. + */ + public function writePCM(string $pcm): void + { + echo 'called pcm'; + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->pcmPauseBuffer[] = $pcm; + + return; + } + + $this->pcmData .= $pcm; + + $this->emit('pcm', [$pcm]); + } + + /** + * Writes Opus audio data. + * + * @param string $opus Opus audio data. + */ + public function writeOpus(string $opus): void + { + echo 'called opus'; + + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->opusPauseBuffer[] = $opus; + + return; + } + + $this->opusData .= $opus; + + $this->emit('opus', [$opus]); + } + + /** + * {@inheritDoc} + */ + public function isReadable() + { + return !$this->isPaused && !$this->isClosed; + } + + /** + * {@inheritDoc} + */ + public function isWritable() + { + return $this->isReadable(); + } + + /** + * {@inheritDoc} + */ + public function write($data) + { + $this->writePCM($data); + } + + /** + * {@inheritDoc} + */ + public function end($data = null) + { + if ($this->isClosed) { + return; + } + + $this->write($data); + $this->close(); + } + + /** + * {@inheritDoc} + */ + public function close() + { + if ($this->isClosed) { + return; + } + + $this->pause(); + $this->emit('end', []); + $this->emit('close', []); + $this->isClosed = true; + } + + /** + * {@inheritDoc} + */ + public function pause() + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + return; + } + + $this->isPaused = true; + } + + /** + * {@inheritDoc} + */ + public function resume() + { + if ($this->isClosed) { + return; + } + + if (! $this->isPaused) { + return; + } + + $this->isPaused = false; + + foreach ($this->pcmPauseBuffer as $data) { + $this->writePCM($data); + } + + foreach ($this->opusPauseBuffer as $data) { + $this->writeOpus($data); + } + } + + /** + * {@inheritDoc} + */ + public function pipe(WritableStreamInterface $dest, array $options = []) + { + $this->pipePCM($dest, $options); + } + + /** + * Pipes PCM to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipePCM(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('pcm', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } + + /** + * Pipes Opus to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipeOpus(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('opus', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } +} diff --git a/src/Discord/Voice/RecieveStream.php b/src/Discord/Voice/RecieveStream.php index 2468093a8..f2d2793e2 100644 --- a/src/Discord/Voice/RecieveStream.php +++ b/src/Discord/Voice/RecieveStream.php @@ -11,272 +11,12 @@ namespace Discord\Voice; -use Evenement\EventEmitter; -use React\Stream\DuplexStreamInterface; -use React\Stream\WritableStreamInterface; - /** * Handles recieving audio from Discord. * + * @deprecated The class was renamed, kept for backwards compatibility. * @since 3.2.0 */ -class RecieveStream extends EventEmitter implements DuplexStreamInterface +class RecieveStream extends ReceiveStream { - /** - * Contains PCM data. - * - * @var string PCM data. - */ - protected $pcmData = ''; - - /** - * Contains Opus data. - * - * @var string Opus data. - */ - protected $opusData = ''; - - /** - * Is the stream paused? - * - * @var bool Whether the stream is paused. - */ - protected $isPaused; - - /** - * Whether the stream is closed. - * - * @var bool Whether the stream is closed. - */ - protected $isClosed = false; - - /** - * The PCM pause buffer. - * - * @var array The PCM pause buffer. - */ - protected $pcmPauseBuffer = []; - - /** - * The pause buffer. - * - * @var array The pause buffer. - */ - protected $opusPauseBuffer = []; - - /** - * Constructs a stream. - */ - public function __construct() - { - // empty for now - } - - /** - * Writes PCM audio data. - * - * @param string $pcm PCM audio data. - */ - public function writePCM(string $pcm): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->pcmPauseBuffer[] = $pcm; - - return; - } - - $this->pcmData .= $pcm; - - $this->emit('pcm', [$pcm]); - } - - /** - * Writes Opus audio data. - * - * @param string $opus Opus audio data. - */ - public function writeOpus(string $opus): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->opusPauseBuffer[] = $opus; - - return; - } - - $this->opusData .= $opus; - - $this->emit('opus', [$opus]); - } - - /** - * {@inheritDoc} - */ - public function isReadable() - { - return $this->isPaused; - } - - /** - * {@inheritDoc} - */ - public function isWritable() - { - return $this->isPaused; - } - - /** - * {@inheritDoc} - */ - public function write($data) - { - $this->writePCM($data); - } - - /** - * {@inheritDoc} - */ - public function end($data = null) - { - if ($this->isClosed) { - return; - } - - $this->write($data); - $this->close(); - } - - /** - * {@inheritDoc} - */ - public function close() - { - if ($this->isClosed) { - return; - } - - $this->pause(); - $this->emit('end', []); - $this->emit('close', []); - $this->isClosed = true; - } - - /** - * {@inheritDoc} - */ - public function pause() - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - return; - } - - $this->isPaused = true; - } - - /** - * {@inheritDoc} - */ - public function resume() - { - if ($this->isClosed) { - return; - } - - if (! $this->isPaused) { - return; - } - - $this->isPaused = false; - - foreach ($this->pcmPauseBuffer as $data) { - $this->writePCM($data); - } - - foreach ($this->opusPauseBuffer as $data) { - $this->writeOpus($data); - } - } - - /** - * {@inheritDoc} - */ - public function pipe(WritableStreamInterface $dest, array $options = []) - { - $this->pipePCM($dest, $options); - } - - /** - * Pipes PCM to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipePCM(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('pcm', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } - - /** - * Pipes Opus to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipeOpus(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('opus', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } } From 25e231dd2cb6750e841ce102d5d4a0fdac19ead3 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 16:54:40 +0000 Subject: [PATCH 004/121] Updates the current version of the voice client to v8 Updates the current decrypting to the latest preferred "aead_aes256_gcm_rtpsize" Adds some missing OP Codes Adds some extra functions to Buffer, that were missing & required for some packets --- .../Voice/AudioAlreadyPlayingException.php | 25 ++ .../Voice/ClientNotReadyException.php | 25 ++ src/Discord/Voice/Buffer.php | 62 ++- src/Discord/Voice/VoiceClient.php | 393 +++++++++--------- src/Discord/Voice/VoicePacket.php | 286 +++++++++++-- src/Discord/WebSockets/Op.php | 8 +- 6 files changed, 563 insertions(+), 236 deletions(-) create mode 100644 src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php create mode 100644 src/Discord/Exceptions/Voice/ClientNotReadyException.php diff --git a/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php b/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php new file mode 100644 index 000000000..5eabe4354 --- /dev/null +++ b/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the Voice Client is already playing audio. + * + * @since 10.0.0 + */ +class AudioAlreadyPlayingException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'Audio is already playing.'); + } +} diff --git a/src/Discord/Exceptions/Voice/ClientNotReadyException.php b/src/Discord/Exceptions/Voice/ClientNotReadyException.php new file mode 100644 index 000000000..4b1451b31 --- /dev/null +++ b/src/Discord/Exceptions/Voice/ClientNotReadyException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the Voice Client is not ready. + * + * @since 10.0.0 + */ +class ClientNotReadyException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'Voice Client is not ready.'); + } +} diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 4d4e49943..956bfa37f 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -13,6 +13,7 @@ use ArrayAccess; use TrafficCophp\ByteBuffer\Buffer as BaseBuffer; +use TrafficCophp\ByteBuffer\FormatPackEnum; /** * A Byte Buffer similar to Buffer in NodeJS. @@ -27,9 +28,9 @@ class Buffer extends BaseBuffer implements ArrayAccess * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeUInt32BE(int $value, int $offset): void + public function writeUInt32BE(int $value, int $offset): self { - $this->insert('I', $value, $offset, 3); + return $this->insert(FormatPackEnum::I, $value, $offset, 3); } /** @@ -38,9 +39,9 @@ public function writeUInt32BE(int $value, int $offset): void * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeUInt64LE(int $value, int $offset): void + public function writeUInt64LE(int $value, int $offset): self { - $this->insert('P', $value, $offset, 8); + return $this->insert(FormatPackEnum::P, $value, $offset, 8); } /** @@ -49,9 +50,20 @@ public function writeUInt64LE(int $value, int $offset): void * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeInt(int $value, int $offset): void + public function writeInt(int $value, int $offset): self { - $this->insert('N', $value, $offset, 4); + return $this->insert(FormatPackEnum::N, $value, $offset, 4); + } + + /** + * Writes a unsigned integer. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeUInt(int $value, int $offset): self + { + return $this->insert(FormatPackEnum::I, $value, $offset, 4); } /** @@ -63,7 +75,19 @@ public function writeInt(int $value, int $offset): void */ public function readInt(int $offset): int { - return $this->extract('N', $offset, 4); + return $this->extract(FormatPackEnum::N, $offset, 4); + } + + /** + * Reads a signed integer. + * + * @param int $offset The offset to read from. + * + * @return int The data read. + */ + public function readUInt(int $offset): int + { + return $this->extract(FormatPackEnum::I, $offset, 4); } /** @@ -72,9 +96,9 @@ public function readInt(int $offset): int * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeShort(int $value, int $offset): void + public function writeShort(int $value, int $offset): self { - $this->insert('n', $value, $offset, 2); + return $this->insert(FormatPackEnum::n, $value, $offset, 2); } /** @@ -86,7 +110,7 @@ public function writeShort(int $value, int $offset): void */ public function readShort(int $offset): int { - return $this->extract('n', $offset, 4); + return $this->extract(FormatPackEnum::n, $offset, 4); } /** @@ -98,7 +122,17 @@ public function readShort(int $offset): int */ public function readUIntLE(int $offset): int { - return $this->extract('I', $offset, 3); + return $this->extract(FormatPackEnum::I, $offset, 3); + } + + public function readChar(int $offset): string + { + return $this->extract(FormatPackEnum::c, $offset, 1); + } + + public function readUChar(int $offset): string + { + return $this->extract(FormatPackEnum::C, $offset, 1); } /** @@ -107,9 +141,9 @@ public function readUIntLE(int $offset): int * @param string $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeChar(string $value, int $offset): void + public function writeChar(string $value, int $offset): self { - $this->insert('c', $value, $offset, $this->lengthMap->getLengthFor('c')); + return $this->insert(FormatPackEnum::c, $value, $offset, FormatPackEnum::c->getLength()); } /** @@ -146,7 +180,7 @@ public function writeRawString(string $value, int $offset): void #[\ReturnTypeWillChange] public function offsetGet($key) { - return $this->buffer[$key]; + return $this->buffer[$key] ?? null; } /** diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index f3df0c046..1398a70c2 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -19,13 +19,15 @@ use Psr\Log\LoggerInterface; use React\Dns\Config\Config; use Ratchet\Client\WebSocket; -use Discord\Parts\User\Member; +use Discord\Voice\VoicePacket; use Discord\Helpers\Collection; use React\ChildProcess\Process; +use Discord\Voice\ReceiveStream; use Discord\Parts\Channel\Channel; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; use React\Promise\PromiseInterface; +use Ratchet\RFC6455\Messaging\Message; use React\Stream\ReadableResourceStream; use Discord\Helpers\Buffer as RealBuffer; use React\Stream\ReadableStreamInterface; @@ -36,11 +38,10 @@ use React\Datagram\Factory as DatagramFactory; use Discord\Exceptions\FFmpegNotFoundException; use Discord\Exceptions\LibSodiumNotFoundException; -use Discord\Exceptions\Voice\AudioAlreadyPlayingException; -use Discord\Exceptions\Voice\ClientNotReadyException; use React\Stream\ReadableResourceStream as Stream; -use Discord\Voice\VoicePacket; -use Ratchet\RFC6455\Messaging\Message; + +use Discord\Exceptions\Voice\ClientNotReadyException; +use Discord\Exceptions\Voice\AudioAlreadyPlayingException; /** * The Discord voice client. @@ -84,20 +85,6 @@ class VoiceClient extends EventEmitter */ protected ?string $ffmpeg; - /** - * The ReactPHP event loop. - * - * @var LoopInterface The ReactPHP event loop that will run everything. - */ - protected $loop; - - /** - * The main WebSocket instance. - * - * @var WebSocket The main WebSocket client. - */ - protected ?WebSocket $mainWebsocket; - /** * The voice WebSocket instance. * @@ -112,20 +99,6 @@ class VoiceClient extends EventEmitter */ public ?Socket $client; - /** - * The Channel that we are connecting to. - * - * @var Channel The channel that we are going to connect to. - */ - protected ?Channel $channel; - - /** - * Data from the main WebSocket. - * - * @var array Information required for the voice WebSocket. - */ - protected ?array $data; - /** * The Voice WebSocket endpoint. * @@ -195,7 +168,7 @@ class VoiceClient extends EventEmitter * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes * @var string The voice mode. */ - protected string $mode = 'aead_xchacha20_poly1305_rtpsize'; + protected string $mode = 'aead_aes256_gcm_rtpsize'; /** * The secret key used for encrypting voice. @@ -218,20 +191,6 @@ class VoiceClient extends EventEmitter */ protected bool $speaking = false; - /** - * Whether we are set as mute. - * - * @var bool Whether we are set as mute. - */ - protected bool $mute = false; - - /** - * Whether we are set as deaf. - * - * @var bool Whether we are set as deaf. - */ - protected bool $deaf = false; - /** * Whether the voice client is currently paused. * @@ -284,10 +243,19 @@ class VoiceClient extends EventEmitter /** * Voice audio recieve streams. * - * @var array Voice audio recieve streams. + * @deprecated 10.5.0 Use receiveStreams instead. + * + * @var array Voice audio recieve streams. */ protected ?array $recieveStreams; + /** + * Voice audio recieve streams. + * + * @var array Voice audio recieve streams. + */ + protected ?array $receiveStreams; + /** * The volume the audio will be encoded with. * @@ -325,16 +293,11 @@ class VoiceClient extends EventEmitter */ protected bool $userClose = false; - /** - * The logger. - * - * @var LoggerInterface|null Logger. - */ - protected $logger; - /** * The Discord voice gateway version. * + * @see https://discord.com/developers/docs/topics/voice-connections#voice-gateway-versioning-gateway-versions + * * @var int Voice version. */ protected int $version = 8; @@ -374,6 +337,8 @@ class VoiceClient extends EventEmitter */ public array $clientsConnected = []; + public array $tempFiles; + /** * Constructs the Voice Client instance. * @@ -383,15 +348,29 @@ class VoiceClient extends EventEmitter * @param LoggerInterface $logger The logger. * @param array $data More information related to the voice client. */ - public function __construct(WebSocket $websocket, LoopInterface $loop, Channel $channel, LoggerInterface $logger, array $data) - { - $this->loop = $loop; - $this->mainWebsocket = $websocket; - $this->channel = $channel; - $this->logger = $logger; - $this->data = $data; - $this->deaf = $data['deaf']; - $this->mute = $data['mute']; + + /** + * Constructs the Voice client instance + * + * @param \Ratchet\Client\WebSocket $mainWebsocket + * @param \React\EventLoop\LoopInterface $loop + * @param \Discord\Parts\Channel\Channel $channel + * @param \Psr\Log\LoggerInterface $logger + * @param array $data + * @param bool $deaf Default: false + * @param bool $mute Default: false + */ + public function __construct( + protected WebSocket $mainWebsocket, + protected LoopInterface $loop, + protected Channel $channel, + protected LoggerInterface $logger, + protected array $data, + protected bool $deaf = false, + protected bool $mute = false, + ) { + $this->deaf = $this->data['deaf'] ?? false; + $this->mute = $this->data['mute'] ?? false; $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); $this->speakingStatus = new Collection([], 'ssrc'); $this->dnsConfig = $data['dnsConfig']; @@ -400,9 +379,9 @@ public function __construct(WebSocket $websocket, LoopInterface $loop, Channel $ /** * Starts the voice client. * - * @return void|bool + * @return bool */ - public function start() + public function start(): bool { if ( ! $this->checkForFFmpeg() || @@ -412,6 +391,7 @@ public function start() } $this->initSockets(); + return true; } /** @@ -444,17 +424,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $ip = $port = ''; $ws->on('message', function (Message $message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port): void { - if ($message?->getPayload() === null || json_decode($message->getPayload() ?? '') === null) { - - dd($message); - return; - } - - $data = json_decode($message->getPayload()); - - echo 'Received: ' . json_encode($data) . PHP_EOL; - $this->emit('ws-message', [$message, $this]); switch ($data->op) { @@ -483,11 +453,15 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->emit('resumed', [$this]); } + if (! $this->deaf && $this->secretKey) { + $this->client->on('message', fn (string $message, string $address, Socket $client) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->logger))); + } + break; case Op::VOICE_SPEAKING: // currently connected users $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); - $this->speakingStatus[$data->d->ssrc] = $data->d; + $this->speakingStatus[$data->d->user_id] = $data->d; break; case Op::VOICE_HELLO: $this->heartbeatInterval = $data->d->heartbeat_interval; @@ -507,9 +481,12 @@ public function handleWebSocketConnection(WebSocket $ws): void $sendHeartbeat(); $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, $sendHeartbeat); break; - case Op::VOICE_CLIENT_CONNECT: + case Op::VOICE_CLIENTS_CONNECTED: # "d" contains an array with ['user_ids' => array] - $this->clientsConnected[] = $data->d->user_ids; + $this->clientsConnected = $data->d->user_ids; + break; + case Op::VOICE_CLIENT_DISCONNECT: + unset($this->clientsConnected[$data->d->user_id]); break; case Op::VOICE_CLIENT_UNKNOWN_15: case Op::VOICE_CLIENT_UNKNOWN_18: @@ -568,7 +545,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('connected to voice UDP'); $this->client = $client; - $this->loop->addTimer(1, function () use ($buffer) { + $this->loop->addTimer(0.1, function () use ($buffer) { $this->client->send((string) $buffer); }); @@ -626,15 +603,9 @@ public function handleWebSocketConnection(WebSocket $ws): void ], ], ]); - - $client->removeListener('message', $decodeUDP); - - if (! $this->deaf) { - $client->on('message', [$this, 'handleAudioData']); - } }; - $client->on('message', $decodeUDP); + $client->once('message', $decodeUDP); }, function (Throwable $e): void { $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); @@ -643,8 +614,7 @@ public function handleWebSocketConnection(WebSocket $ws): void break; } default: - echo 'Unknown opcode: ' . $data->op . PHP_EOL; - echo 'Data: ' . json_encode($data) . PHP_EOL; + $this->logger->warning('Unknown opcode.', $data); break; } }); @@ -1106,6 +1076,7 @@ private function reset(): void * Sends a buffer to the UDP socket. * * @param string $data The data to send to the UDP server. + * @todo Fix after new change in VoicePacket */ private function sendBuffer(string $data): void { @@ -1472,17 +1443,31 @@ public function handleVoiceStateUpdate(object $data): void * * @param int|string $id Either a SSRC or User ID. * + * @deprecated 10.5.0 Use getReceiveStream instead. + * * @return null|RecieveStream|ReceiveStream */ public function getRecieveStream($id): null|RecieveStream|ReceiveStream { - if (isset($this->recieveStreams[$id])) { - return $this->recieveStreams[$id]; + return $this->getReceiveStream($id); + } + + /** + * Gets a receive voice stream. + * + * @param int|string $id Either a SSRC or User ID. + * + * @return null|ReceiveStream + */ + public function getReceiveStream($id): null|ReceiveStream + { + if (isset($this->receiveStreams[$id])) { + return $this->receiveStreams[$id]; } foreach ($this->speakingStatus as $status) { if ($status->user_id == $id) { - return $this->recieveStreams[$status->ssrc]; + return $this->receiveStreams[$status->ssrc]; } } @@ -1494,140 +1479,138 @@ public function getRecieveStream($id): null|RecieveStream|ReceiveStream * * @param string $message The data from the UDP server. */ - protected function handleAudioData(string $message): void + protected function handleAudioData(VoicePacket $voicePacket): void { - # echo 'Handling audio data' . PHP_EOL; - # echo 'Message: ' . $message . PHP_EOL; - - - - ##### NON AI CODE DOWN HERE ######### - - $voicePacket = new VoicePacket(); - // $voicePacket->unpack($message); + $message = $voicePacket?->decryptedAudio ?? null; - - // Supondo que $message contenha a mensagem completa e $this->secretKey esteja definida. - $len = strlen($message); - - // 1. Calcular o tamanho do header (12 bytes ou 16 se o bit de extensão estiver setado) - $headerSize = 12; - $firstByte = ord($message[0]); - if (($firstByte >> 4) & 0x01) { - $headerSize += 4; - } - - // 2. Extrair o header - $header = substr($message, 0, $headerSize); - - // 3. Preparar o nonce: pegar os últimos 4 bytes e preencher à esquerda com zeros - $nonce = substr($message, $len - 4, 4); - $nonceBuffer = str_pad($nonce, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, "\0", STR_PAD_RIGHT); - - // 4. Extrair o ciphertext e a tag de autenticação - // A mensagem: [header][ciphertext][auth tag][nonce] - // O tamanho do ciphertext é: total - headerSize - 16 (auth tag) - 4 (nonce) - $encryptedLength = $len - $headerSize - 16 - 4; - $cipherText = substr($message, $headerSize, $encryptedLength); - $authTag = substr($message, $headerSize + $encryptedLength, 16); - - // Concatenar ciphertext e auth tag, como na versão JS - $combined = $cipherText . $authTag; - - try { - // 5. Decriptar a mensagem usando o sodium - $resultMessage = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( - $combined, - $header, - $nonceBuffer, - $this->secretKey - ); - - if ($resultMessage === false) { - // Se a decriptação falhar, log o erro e retorne - $this->logger->error('Failed to decode voice packet.', [ - 'padded_nonce' => $nonceBuffer, - 'message_len' => $len, - ]); + if (! $message) { + if (! $this->speakingStatus->get('ssrc', $voicePacket->getSSRC())) { + // We don't have a speaking status for this SSRC + // Probably a "ping" to the udp socket return; } - - // 6. Verificar e remover a extensão, se presente - if (substr($message, 12, 2) === "\xBE\xDE") { - // Lê os 2 bytes após o identificador da extensão para obter o tamanho da extensão - $extLengthData = substr($message, 14, 2); - $headerExtensionLength = unpack('n', $extLengthData)[1]; - // Remove 4 * headerExtensionLength bytes do início do resultado decriptado - $resultMessage = substr($resultMessage, 4 * $headerExtensionLength); - } - } catch (\Exception $e) { - $this->logger->error('Exception occurred when decoding voice packet: ' . $e->getMessage()); + // There's no message or the message threw an error inside the decrypt function + $this->logger->warning('No audio data.', ['voicePacket' => $voicePacket]); return; } - dd('Success!', $resultMessage); - - $message = $resultMessage; $this->emit('raw', [$message, $this]); - $vp = VoicePacket::make($voicePacket->getHeader().$message); - $ss = $this->speakingStatus->get('ssrc', $vp->getSSRC()); - $decoder = $this->voiceDecoders[$vp->getSSRC()] ?? null; + $ss = $this->speakingStatus->get('ssrc', $voicePacket->getSSRC()); + $decoder = $this->voiceDecoders[$voicePacket->getSSRC()] ?? null; if (null === $ss) { // for some reason we don't have a speaking status - $this->logger->warning('Received voice packet from unknown SSRC.', ['ssrc' => $vp->getSSRC()]); + $this->logger->warning('Unknown SSRC.', ['ssrc' => $voicePacket->getSSRC(), 't' => $voicePacket->getTimestamp()]); return; } if (null === $decoder) { + echo 'Creating decoder for ' . $voicePacket->getSSRC() . PHP_EOL; // make a decoder - if (! isset($this->recieveStreams[$ss->ssrc])) { - $this->recieveStreams[$ss->ssrc] = new RecieveStream(); + if (! isset($this->receiveStreams[$ss->ssrc])) { + $this->receiveStreams[$ss->ssrc] = new ReceiveStream(); - $this->recieveStreams[$ss->ssrc]->on('pcm', function ($d) { + $this->receiveStreams[$ss->ssrc]->on('pcm', function ($d) { echo 'PCM: ' . $d . PHP_EOL; $this->emit('channel-pcm', [$d, $this]); }); - $this->recieveStreams[$ss->ssrc]->on('opus', function ($d) { + $this->receiveStreams[$ss->ssrc]->on('opus', function ($d) { echo 'OPUS: ' . $d . PHP_EOL; $this->emit('channel-opus', [$d, $this]); }); } $createDecoder = function () use (&$createDecoder, $ss) { - $decoder = $this->dcaDecode(); + $decoder = $this->ffmpegDecode(); $decoder->start($this->loop); - $decoder->stdout->on('data', function ($data) use ($ss) { - echo 'Data: ' . $data . PHP_EOL; - $this->recieveStreams[$ss->ssrc]->writePCM($data); - }); - $decoder->stderr->on('data', function ($data) use ($ss) { - $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); - $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); + // Handle stdout + $stdoutHandle = fopen($this->tempFiles['stdout'], 'r'); + $this->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { + $data = fread($stdoutHandle, 8192); + if ($data) { + $this->receiveStreams[$ss->ssrc]->writePCM($data); + } }); - $decoder->on('exit', function ($code, $term) use ($ss, &$createDecoder) { - if ($code > 0) { - $this->emit('decoder-error', [$code, $term, $ss]); - $createDecoder(); + // Handle stderr + $stderrHandle = fopen($this->tempFiles['stderr'], 'r'); + $this->loop->addPeriodicTimer(0.1, function () use ($stderrHandle, $ss) { + $data = fread($stderrHandle, 8192); + if ($data) { + $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); + $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); } }); + // Store the decoder $this->voiceDecoders[$ss->ssrc] = $decoder; + + // Monitor the process for exit + $this->monitorProcessExit($decoder, $ss, $createDecoder); }; $createDecoder(); - $decoder = $this->voiceDecoders[$vp->getSSRC()] ?? null; + $decoder = $this->voiceDecoders[$voicePacket->getSSRC()] ?? null; } - $buff = new Buffer(strlen($vp->getData()) + 2); - $buff->write(pack('s', strlen($vp->getData())), 0); - $buff->write($vp->getData(), 2); + $audioData = $voicePacket->getAudioData(); + + $buff = new Buffer(strlen($audioData) + 2); + $buff->write(pack('s', strlen($audioData)), 0); + $buff->write($audioData, 2); + + $stdinHandle = fopen($this->tempFiles['stdin'], 'a'); // Use append mode + fwrite($stdinHandle, (string) $buff); + fflush($stdinHandle); // Make sure the data is written immediately + fclose($stdinHandle); + } + + /** + * Monitor a process for exit and trigger callbacks when it exits + * + * @param Process $process The process to monitor + * @param object $ss The speaking status object + * @param callable $createDecoder Function to create a new decoder if needed + */ + private function monitorProcessExit(Process $process, $ss, callable $createDecoder): void + { + // Store the process ID + $pid = $process->getPid(); + + // Check every second if the process is still running + $timer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss, &$createDecoder, &$timer, $pid) { + // Check if the process is still running + if (!$process->isRunning()) { + // Get the exit code + $exitCode = $process->getExitCode(); + + // Clean up the timer + $this->loop->cancelTimer($timer); + + // If exit code indicates an error, emit event and recreate decoder + if ($exitCode > 0) { + $this->emit('decoder-error', [$exitCode, null, $ss]); + $createDecoder(); + } + + // Clean up temporary files + $this->cleanupTempFiles(); + } + }); + } - $decoder->stdin->write((string) $buff); + private function cleanupTempFiles(): void + { + if (isset($this->tempFiles)) { + foreach ($this->tempFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + } } private function handleDavePrepareTransition($data) @@ -1877,6 +1860,45 @@ public function dcaDecode(int $channels = 2, ?int $frameSize = null): Process return new Process("{$this->dca} {$flags}"); } + public function ffmpegDecode(int $channels = 2, ?int $frameSize = null): Process + { + if (null === $frameSize) { + $frameSize = round($this->frameSize * 48); + } + + $flags = [ + '-ac:opus', $channels, // Channels + '-ab', round($this->bitrate / 1000), // Bitrate + '-as', $frameSize, // Frame Size + '-ar', '48000', // Audio Rate + '-mode', 'decode', // Decode mode + ]; + + $flags = implode(' ', $flags); + + // Create temporary files for stdin, stdout, and stderr + $tempDir = sys_get_temp_dir(); + $stdinFile = tempnam($tempDir, 'discord_ffmpeg_stdin_' . $this->ssrc); + $stdoutFile = tempnam($tempDir, 'discord_ffmpeg_stdout_' . $this->ssrc); + $stderrFile = tempnam($tempDir, 'discord_ffmpeg_stderr_' . $this->ssrc); + + // Store temp file paths for later cleanup + $this->tempFiles = [ + 'stdin' => $stdinFile, + 'stdout' => $stdoutFile, + 'stderr' => $stderrFile, + ]; + + return new Process( + "{$this->ffmpeg} {$flags}", + fds: [ + ['file', $stdinFile, 'w'], + ['file', $stdoutFile, 'w+'], + ['file', $stderrFile, 'w+'], + ] + ); + } + /** * Returns the connected channel. * @@ -1898,4 +1920,5 @@ public function insertSilence(): void $this->sendBuffer(self::SILENCE_FRAME); } } + } diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index 95658958e..a46fe5f49 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -11,6 +11,9 @@ namespace Discord\Voice; +use Monolog\Logger; +use TrafficCophp\ByteBuffer\FormatPackEnum; + /** * A voice packet received from Discord. * @@ -22,20 +25,41 @@ */ class VoicePacket { + + # RTP Header Constants public const RTP_HEADER_BYTE_LENGTH = 12; public const RTP_VERSION_PAD_EXTEND_INDEX = 0; + public const RTP_VERSION_PAD_EXTEND = 0x80; public const RTP_PAYLOAD_INDEX = 1; + public const RTP_PAYLOAD_TYPE = 0x78; public const SEQ_INDEX = 2; + public const TIMESTAMP_INDEX = 4; + public const SSRC_INDEX = 8; + public const NONCE_LENGTH = 12; + + public const NONCE_BYTE_LENGTH = 4; + + public const AUTH_TAG_LENGTH = 16; + + /** + * The audio header, in binary, containing the version, flags, sequence, timestamp, and SSRC. + * + * @var string + */ + protected string $header; + /** - * The voice packet buffer. + * The buffer containing the voice packet. + * + * @deprecated * * @var Buffer */ @@ -46,21 +70,70 @@ class VoicePacket * * @var int The client SSRC. */ - protected $ssrc; + public ?int $ssrc; /** * The packet sequence. * * @var int The packet sequence. */ - protected $seq; + public ?int $seq; /** * The packet timestamp. * * @var int The packet timestamp. */ - protected $timestamp; + public ?int $timestamp; + + /** + * The version and flags. + * + * @var string The version and flags. + */ + public ?string $versionPlusFlags; + + /** + * The payload type. + * + * @var string The payload type. + */ + public ?string $payloadType; + + /** + * The encrypted audio. + * + * @var string The encrypted audio. + */ + public ?string $encryptedAudio; + + /** + * The dencrypted audio. + * + * @var string + */ + public null|string|false $decryptedAudio; + + /** + * The secret key. + * + * @var string The secret key. + */ + public ?string $secretKey; + + /** + * The raw data + * + * @var string + */ + private string $rawData; + + /** + * Current packet header size. May differ depending on the RTP header. + * + * @var int + */ + private int $headerSize; /** * Constructs the voice packet. @@ -72,22 +145,142 @@ class VoicePacket * @param bool $encryption Whether the packet should be encrypted. * @param string|null $key The encryption key. */ - public function __construct(string $data, int $ssrc, int $seq, int $timestamp, bool $encryption = false, ?string $key = null) + public function __construct(?string $data = null, ?int $ssrc = null, ?int $seq = null, ?int $timestamp = null, bool $encryption = false, private ?string $key = null, private ?Logger $log = null) + { + $this->unpack($data) + ->decrypt(); + } + + /** + * Unpacks the voice message into an array. + * + * C1 (unsigned char) | Version + Flags | 1 bytes | Single byte value of 0x80 + * C1 (unsigned char) | Payload Type | 1 bytes | Single byte value of 0x78 + * n (Unsigned short (big endian)) | Sequence | 2 bytes + * I (Unsigned integer (big endian)) | Timestamp | 4 bytes + * I (Unsigned integer (big endian)) | SSRC | 4 bytes + * a* (string) | Encrypted audio | n bytes | Binary data of the encrypted audio. + * + * @see https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes-voice-packet-structure + * @see https://www.php.net/manual/en/function.unpack.php + * @see https://www.php.net/manual/en/function.pack.php For the formats + */ + public function unpack(string $message): self + { + $byteHeader = $this->setHeader($message); + + if (! $byteHeader) { + $this->log->warning('Failed to unpack voice packet Header.', ['message' => $message]); + echo 'Failed to unpack voice packet Header.' . PHP_EOL; + return $this; + } + + $byteData = substr( + $message, + self::RTP_HEADER_BYTE_LENGTH, + strlen($message) - self::AUTH_TAG_LENGTH - self::NONCE_LENGTH + ); + + $unpackedMessage = unpack('Cfirst/Csecond/nseq/Ntimestamp/Nssrc', $byteHeader); + + if (! $unpackedMessage) { + $this->log->warning('Failed to unpack voice packet.', ['message' => $message]); + return $this; + } + + $this->rawData = $message; + $this->header = $byteHeader; + $this->encryptedAudio = $byteData; + + $this->ssrc = $unpackedMessage['ssrc']; + $this->seq = $unpackedMessage['seq']; + $this->timestamp = $unpackedMessage['timestamp']; + $this->payloadType = $unpackedMessage['payload_type'] ?? null; + $this->versionPlusFlags = $unpackedMessage['version_and_flags'] ?? null; + + return $this; + } + + /** + * Decrypts the voice message. + * + * @param string|null $message The message to decrypt. + * + * @return false|null|string + */ + public function decrypt(?string $message = null): false|null|string { - $this->ssrc = $ssrc; - $this->seq = $seq; - $this->timestamp = $timestamp; + if (! $message) { + $message = $this?->rawData ?? null; + } + + if (empty($message)) { + // throw error here + return null; + } + + $len = strlen($message); + + // 2. Extract the header + $header = $this->getHeader(); + if (! $header) { + $this->log->warning('Invalid Voice Header.', ['message' => $message]); + return false; + } + + // 3. Extract the nonce + $nonce = substr($message, $len - self::NONCE_BYTE_LENGTH, self::NONCE_BYTE_LENGTH); + // 4. Pad the nonce to 12 bytes + $nonceBuffer = str_pad($nonce, SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, "\0", STR_PAD_RIGHT); + + // 5. Extract the ciphertext and auth tag + // The message: [header][ciphertext][auth tag][nonce] + // The size of the ciphertext is: total - headerSize - 16 (auth tag) - 4 (nonce) + $encryptedLength = $len - $this->headerSize - self::AUTH_TAG_LENGTH - self::NONCE_BYTE_LENGTH; + $cipherText = substr($message, $this->headerSize, $encryptedLength); + $authTag = substr($message, $this->headerSize + $encryptedLength, self::AUTH_TAG_LENGTH); + + // Concatenate the ciphertext and the auth tag + $combined = "$cipherText$authTag"; + + $resultMessage = null; + + try { + // Decrypt the message + $resultMessage = sodium_crypto_aead_aes256gcm_decrypt( + $combined, + $header, + $nonceBuffer, + $this->key + ); + + // If decryption fails, log the error and return + // Most of the time, the length is 20 bytes either for a ping, or an empty voice/udp packet + if ($resultMessage === false && strlen($cipherText) !== 20) { + $this->log->warning('Failed to decode voice packet.', ['ssrc' => $this->ssrc]); + } + // Check if the message contains an extension and remove it + elseif (substr($message, 12, 2) === "\xBE\xDE") { + // Reads the 2 bytes after the extension identifier to get the extension length + $extLengthData = substr($message, 14, 2); + $headerExtensionLength = unpack('n', $extLengthData)[1]; - if (! $encryption) { - $this->initBufferNoEncryption($data); - } else { - $this->initBufferEncryption($data, $key); + // Remove 4 * headerExtensionLength bytes from the beginning of the decrypted result + $resultMessage = substr($resultMessage, 4 * $headerExtensionLength); + } + } catch (\Throwable $e) { + $this->log->error('Exception occurred when decoding voice packet: ' . $e->getMessage()); + $this->log->error('Trace: ' . $e->getTraceAsString()); + } finally { + return $this->decryptedAudio = $resultMessage; } } /** * Initilizes the buffer with no encryption. * + * @deprecated + * * @param string $data The Opus data to encode. */ protected function initBufferNoEncryption(string $data): void @@ -95,11 +288,9 @@ protected function initBufferNoEncryption(string $data): void $data = (binary) $data; $header = $this->buildHeader(); - $buffer = new Buffer(strlen((string) $header) + strlen($data)); - $buffer->write((string) $header, 0); - $buffer->write($data, 12); - - $this->buffer = $buffer; + $this->buffer = Buffer::make(strlen((string) $header) + strlen($data)) + ->write((string) $header, 0) + ->write($data, 12); } /** @@ -130,13 +321,36 @@ protected function initBufferEncryption(string $data, string $key): void protected function buildHeader(): Buffer { $header = new Buffer(self::RTP_HEADER_BYTE_LENGTH); - $header[self::RTP_VERSION_PAD_EXTEND_INDEX] = pack('c', self::RTP_VERSION_PAD_EXTEND); - $header[self::RTP_PAYLOAD_INDEX] = pack('c', self::RTP_PAYLOAD_TYPE); - $header->writeShort($this->seq, self::SEQ_INDEX); - $header->writeInt($this->timestamp, self::TIMESTAMP_INDEX); - $header->writeInt($this->ssrc, self::SSRC_INDEX); + $header[self::RTP_VERSION_PAD_EXTEND_INDEX] = pack(FormatPackEnum::C->value, self::RTP_VERSION_PAD_EXTEND); + $header[self::RTP_PAYLOAD_INDEX] = pack(FormatPackEnum::C->value, self::RTP_PAYLOAD_TYPE); + return $header->writeShort($this->seq, self::SEQ_INDEX) + ->writeUInt($this->timestamp, self::TIMESTAMP_INDEX) + ->writeUInt($this->ssrc, self::SSRC_INDEX); + } + + public function setHeader(?string $message = null): ?string + { + if (null === $message) { + $message = $this?->rawData; + } + + if (empty($message)) { + // throw error here + return null; + } + + $this->headerSize = self::RTP_HEADER_BYTE_LENGTH; + $firstByte = ord($message[0]); + if (($firstByte >> 4) & 0x01) { + $this->headerSize += 4; + } + + return substr($message, 0, $this->headerSize); + } - return $header; + public function getHeader(): ?string + { + return $this?->header ?? null; } /** @@ -169,16 +383,6 @@ public function getSSRC(): int return $this->ssrc; } - /** - * Returns the header. - * - * @return string The packet header. - */ - public function getHeader(): string - { - return $this->buffer->read(0, self::RTP_HEADER_BYTE_LENGTH); - } - /** * Returns the data. * @@ -201,6 +405,7 @@ public static function make(string $data): VoicePacket $n = new self('', 0, 0, 0); $buff = new Buffer($data); $n->setBuffer($buff); + unset($buff); return $n; } @@ -217,8 +422,8 @@ public function setBuffer(Buffer $buffer): self $this->buffer = $buffer; $this->seq = $this->buffer->readShort(self::SEQ_INDEX); - $this->timestamp = $this->buffer->readInt(self::TIMESTAMP_INDEX); - $this->ssrc = $this->buffer->readInt(self::SSRC_INDEX); + $this->timestamp = $this->buffer->readUInt(self::TIMESTAMP_INDEX); + $this->ssrc = $this->buffer->readUInt(self::SSRC_INDEX); return $this; } @@ -232,4 +437,15 @@ public function __toString(): string { return (string) $this->buffer; } + + /** + * Retrieves the decrypted audio data. + * Will return null if the audio data is not decrypted and false on error. + * + * @return null|string|false + */ + public function getAudioData(): null|string|false + { + return $this?->decryptedAudio; + } } diff --git a/src/Discord/WebSockets/Op.php b/src/Discord/WebSockets/Op.php index 8abe5461d..51f7e673f 100644 --- a/src/Discord/WebSockets/Op.php +++ b/src/Discord/WebSockets/Op.php @@ -57,7 +57,6 @@ class Op /** Request soundboard sounds. */ public const REQUEST_SOUNDBOARD_SOUNDS = 31; - /** * Voice Opcodes. * @@ -87,9 +86,14 @@ class Op /** Acknowledge a successful session resume. */ public const VOICE_RESUMED = 9; /** One or more clients have connected to the voice channel */ - public const VOICE_CLIENT_CONNECT = 11; + public const VOICE_CLIENTS_CONNECTED = 11; /** A client has disconnected from the voice channel. */ public const VOICE_CLIENT_DISCONNECT = 13; + /** Was not documented within the op codes and statuses*/ + public const VOICE_CLIENT_UNKNOWN_15 = 15; + public const VOICE_CLIENT_UNKNOWN_18 = 18; + /** NOT DOCUMENTED - Assumed to be the platform type in which the user is. */ + public const VOICE_CLIENT_PLATFORM = 20; /** A downgrade from the DAVE protocol is upcoming. */ public const VOICE_DAVE_PREPARE_TRANSITION = 21; /** Execute a previously announced protocol transition. */ From ffda2b111f31d5fdec62ee9e7770a08c1a15fdf1 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 17:03:42 +0000 Subject: [PATCH 005/121] Removes debugging logs --- src/Discord/Voice/ReceiveStream.php | 3 --- src/Discord/Voice/VoiceClient.php | 3 --- src/Discord/Voice/VoicePacket.php | 1 - 3 files changed, 7 deletions(-) diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php index 84be35183..85a4473df 100644 --- a/src/Discord/Voice/ReceiveStream.php +++ b/src/Discord/Voice/ReceiveStream.php @@ -80,7 +80,6 @@ public function __construct() */ public function writePCM(string $pcm): void { - echo 'called pcm'; if ($this->isClosed) { return; } @@ -103,8 +102,6 @@ public function writePCM(string $pcm): void */ public function writeOpus(string $opus): void { - echo 'called opus'; - if ($this->isClosed) { return; } diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 1398a70c2..a20cb00e7 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -1506,18 +1506,15 @@ protected function handleAudioData(VoicePacket $voicePacket): void } if (null === $decoder) { - echo 'Creating decoder for ' . $voicePacket->getSSRC() . PHP_EOL; // make a decoder if (! isset($this->receiveStreams[$ss->ssrc])) { $this->receiveStreams[$ss->ssrc] = new ReceiveStream(); $this->receiveStreams[$ss->ssrc]->on('pcm', function ($d) { - echo 'PCM: ' . $d . PHP_EOL; $this->emit('channel-pcm', [$d, $this]); }); $this->receiveStreams[$ss->ssrc]->on('opus', function ($d) { - echo 'OPUS: ' . $d . PHP_EOL; $this->emit('channel-opus', [$d, $this]); }); } diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index a46fe5f49..8b3763ebf 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -171,7 +171,6 @@ public function unpack(string $message): self if (! $byteHeader) { $this->log->warning('Failed to unpack voice packet Header.', ['message' => $message]); - echo 'Failed to unpack voice packet Header.' . PHP_EOL; return $this; } From 9b3739f7f771a04a154f520a1db53ff00d72e61b Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 14 Mar 2025 12:51:01 +0000 Subject: [PATCH 006/121] Updates const name --- src/Discord/Voice/VoiceClient.php | 2 +- src/Discord/WebSockets/Op.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index a20cb00e7..e2ecc696e 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -481,7 +481,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $sendHeartbeat(); $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, $sendHeartbeat); break; - case Op::VOICE_CLIENTS_CONNECTED: + case Op::VOICE_CLIENTS_CONNECT: # "d" contains an array with ['user_ids' => array] $this->clientsConnected = $data->d->user_ids; break; diff --git a/src/Discord/WebSockets/Op.php b/src/Discord/WebSockets/Op.php index 51f7e673f..f63ea9ac1 100644 --- a/src/Discord/WebSockets/Op.php +++ b/src/Discord/WebSockets/Op.php @@ -86,7 +86,7 @@ class Op /** Acknowledge a successful session resume. */ public const VOICE_RESUMED = 9; /** One or more clients have connected to the voice channel */ - public const VOICE_CLIENTS_CONNECTED = 11; + public const VOICE_CLIENTS_CONNECT = 11; /** A client has disconnected from the voice channel. */ public const VOICE_CLIENT_DISCONNECT = 13; /** Was not documented within the op codes and statuses*/ From 1b3ef2efa0ef9e223e54abdd0b2a0a9615a07dc1 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 14 Mar 2025 14:40:31 +0000 Subject: [PATCH 007/121] Updates recieve class. --- src/Discord/Voice/ReceiveStream.php | 259 +-------------------------- src/Discord/Voice/RecieveStream.php | 263 +++++++++++++++++++++++++++- 2 files changed, 263 insertions(+), 259 deletions(-) diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php index 85a4473df..4162980bc 100644 --- a/src/Discord/Voice/ReceiveStream.php +++ b/src/Discord/Voice/ReceiveStream.php @@ -21,263 +21,6 @@ * @since 10.5.0 The class was renamed to ReceiveStream. * @since 3.2.0 */ -class ReceiveStream extends EventEmitter implements DuplexStreamInterface +class ReceiveStream extends RecieveStream { - /** - * Contains PCM data. - * - * @var string PCM data. - */ - protected $pcmData = ''; - - /** - * Contains Opus data. - * - * @var string Opus data. - */ - protected $opusData = ''; - - /** - * Is the stream paused? - * - * @var bool Whether the stream is paused. - */ - protected $isPaused; - - /** - * Whether the stream is closed. - * - * @var bool Whether the stream is closed. - */ - protected $isClosed = false; - - /** - * The PCM pause buffer. - * - * @var array The PCM pause buffer. - */ - protected $pcmPauseBuffer = []; - - /** - * The pause buffer. - * - * @var array The pause buffer. - */ - protected $opusPauseBuffer = []; - - /** - * Constructs a stream. - */ - public function __construct() - { - // empty for now - } - - /** - * Writes PCM audio data. - * - * @param string $pcm PCM audio data. - */ - public function writePCM(string $pcm): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->pcmPauseBuffer[] = $pcm; - - return; - } - - $this->pcmData .= $pcm; - - $this->emit('pcm', [$pcm]); - } - - /** - * Writes Opus audio data. - * - * @param string $opus Opus audio data. - */ - public function writeOpus(string $opus): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->opusPauseBuffer[] = $opus; - - return; - } - - $this->opusData .= $opus; - - $this->emit('opus', [$opus]); - } - - /** - * {@inheritDoc} - */ - public function isReadable() - { - return !$this->isPaused && !$this->isClosed; - } - - /** - * {@inheritDoc} - */ - public function isWritable() - { - return $this->isReadable(); - } - - /** - * {@inheritDoc} - */ - public function write($data) - { - $this->writePCM($data); - } - - /** - * {@inheritDoc} - */ - public function end($data = null) - { - if ($this->isClosed) { - return; - } - - $this->write($data); - $this->close(); - } - - /** - * {@inheritDoc} - */ - public function close() - { - if ($this->isClosed) { - return; - } - - $this->pause(); - $this->emit('end', []); - $this->emit('close', []); - $this->isClosed = true; - } - - /** - * {@inheritDoc} - */ - public function pause() - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - return; - } - - $this->isPaused = true; - } - - /** - * {@inheritDoc} - */ - public function resume() - { - if ($this->isClosed) { - return; - } - - if (! $this->isPaused) { - return; - } - - $this->isPaused = false; - - foreach ($this->pcmPauseBuffer as $data) { - $this->writePCM($data); - } - - foreach ($this->opusPauseBuffer as $data) { - $this->writeOpus($data); - } - } - - /** - * {@inheritDoc} - */ - public function pipe(WritableStreamInterface $dest, array $options = []) - { - $this->pipePCM($dest, $options); - } - - /** - * Pipes PCM to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipePCM(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('pcm', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } - - /** - * Pipes Opus to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipeOpus(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('opus', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } } diff --git a/src/Discord/Voice/RecieveStream.php b/src/Discord/Voice/RecieveStream.php index f2d2793e2..53947112e 100644 --- a/src/Discord/Voice/RecieveStream.php +++ b/src/Discord/Voice/RecieveStream.php @@ -11,12 +11,273 @@ namespace Discord\Voice; +use Evenement\EventEmitter; +use React\Stream\DuplexStreamInterface; +use React\Stream\WritableStreamInterface; + /** * Handles recieving audio from Discord. * * @deprecated The class was renamed, kept for backwards compatibility. * @since 3.2.0 */ -class RecieveStream extends ReceiveStream +class RecieveStream extends EventEmitter implements DuplexStreamInterface { + /** + * Contains PCM data. + * + * @var string PCM data. + */ + protected $pcmData = ''; + + /** + * Contains Opus data. + * + * @var string Opus data. + */ + protected $opusData = ''; + + /** + * Is the stream paused? + * + * @var bool Whether the stream is paused. + */ + protected $isPaused; + + /** + * Whether the stream is closed. + * + * @var bool Whether the stream is closed. + */ + protected $isClosed = false; + + /** + * The PCM pause buffer. + * + * @var array The PCM pause buffer. + */ + protected $pcmPauseBuffer = []; + + /** + * The pause buffer. + * + * @var array The pause buffer. + */ + protected $opusPauseBuffer = []; + + /** + * Constructs a stream. + */ + public function __construct() + { + // empty for now + } + + /** + * Writes PCM audio data. + * + * @param string $pcm PCM audio data. + */ + public function writePCM(string $pcm): void + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->pcmPauseBuffer[] = $pcm; + + return; + } + + $this->pcmData .= $pcm; + + $this->emit('pcm', [$pcm]); + } + + /** + * Writes Opus audio data. + * + * @param string $opus Opus audio data. + */ + public function writeOpus(string $opus): void + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->opusPauseBuffer[] = $opus; + + return; + } + + $this->opusData .= $opus; + + $this->emit('opus', [$opus]); + } + + /** + * {@inheritDoc} + */ + public function isReadable() + { + return !$this->isPaused && !$this->isClosed; + } + + /** + * {@inheritDoc} + */ + public function isWritable() + { + return $this->isReadable(); + } + + /** + * {@inheritDoc} + */ + public function write($data) + { + $this->writePCM($data); + } + + /** + * {@inheritDoc} + */ + public function end($data = null) + { + if ($this->isClosed) { + return; + } + + $this->write($data); + $this->close(); + } + + /** + * {@inheritDoc} + */ + public function close() + { + if ($this->isClosed) { + return; + } + + $this->pause(); + $this->emit('end', []); + $this->emit('close', []); + $this->isClosed = true; + } + + /** + * {@inheritDoc} + */ + public function pause() + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + return; + } + + $this->isPaused = true; + } + + /** + * {@inheritDoc} + */ + public function resume() + { + if ($this->isClosed) { + return; + } + + if (! $this->isPaused) { + return; + } + + $this->isPaused = false; + + foreach ($this->pcmPauseBuffer as $data) { + $this->writePCM($data); + } + + foreach ($this->opusPauseBuffer as $data) { + $this->writeOpus($data); + } + } + + /** + * {@inheritDoc} + */ + public function pipe(WritableStreamInterface $dest, array $options = []) + { + $this->pipePCM($dest, $options); + } + + /** + * Pipes PCM to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipePCM(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('pcm', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } + + /** + * Pipes Opus to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipeOpus(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('opus', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } } From 59c93d609876337edede430c845c604ea200a6e1 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 16 Mar 2025 17:13:01 +0000 Subject: [PATCH 008/121] Updates Discord class usage for voices into 1 class only. --- src/Discord/Discord.php | 191 ++++++++---------------------- src/Discord/Voice/Voice.php | 152 ++++++++++++++++++++++++ src/Discord/Voice/VoiceClient.php | 37 ++---- 3 files changed, 212 insertions(+), 168 deletions(-) create mode 100644 src/Discord/Voice/Voice.php diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 6a1c3a79e..304864f7a 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -11,52 +11,53 @@ namespace Discord; -use Discord\Exceptions\IntentException; -use Discord\Factory\Factory; -use Discord\Helpers\BigInt; -use Discord\Helpers\CacheConfig; -use Discord\Helpers\RegisteredCommand; -use Discord\Http\Drivers\React; -use Discord\Http\Endpoint; +use Monolog\Level; use Discord\Http\Http; -use Discord\Parts\Channel\Channel; -use Discord\Parts\Guild\Guild; -use Discord\Parts\OAuth\Application; use Discord\Parts\Part; -use Discord\Parts\User\Activity; +use Discord\Voice\Voice; +use React\EventLoop\Loop; +use Discord\Http\Endpoint; +use Discord\WebSockets\Op; +use Discord\Helpers\BigInt; +use React\Promise\Deferred; +use Discord\Factory\Factory; +use Discord\Parts\User\User; +use Psr\Log\LoggerInterface; +use Discord\WebSockets\Event; +use Ratchet\Client\Connector; +use Ratchet\Client\WebSocket; +use Discord\Parts\Guild\Guild; use Discord\Parts\User\Client; use Discord\Parts\User\Member; -use Discord\Parts\User\User; -use Discord\Repository\AbstractRepository; -use Discord\Repository\EmojiRepository; -use Discord\Repository\GuildRepository; -use Discord\Repository\PrivateChannelRepository; -use Discord\Repository\UserRepository; use Discord\Voice\VoiceClient; -use Discord\WebSockets\Event; -use Discord\WebSockets\Events\GuildCreate; -use Discord\WebSockets\Handlers; +use Monolog\Logger as Monolog; +use Discord\Http\Drivers\React; use Discord\WebSockets\Intents; -use Discord\WebSockets\Op; +use function React\Promise\all; +use Discord\Helpers\CacheConfig; +use Discord\Parts\User\Activity; +use Discord\WebSockets\Handlers; use Evenement\EventEmitterTrait; -use Monolog\Formatter\LineFormatter; +use Discord\Parts\Channel\Channel; use Monolog\Handler\StreamHandler; -use Monolog\Logger as Monolog; -use Psr\Log\LoggerInterface; -use Ratchet\Client\Connector; -use Ratchet\Client\WebSocket; -use Ratchet\RFC6455\Messaging\Message; -use React\EventLoop\Loop; use React\EventLoop\LoopInterface; +use function React\Async\coroutine; use React\EventLoop\TimerInterface; -use React\Promise\Deferred; use React\Promise\PromiseInterface; +use Discord\Parts\OAuth\Application; +use Monolog\Formatter\LineFormatter; +use Discord\Helpers\RegisteredCommand; +use Discord\Repository\UserRepository; +use Ratchet\RFC6455\Messaging\Message; +use Discord\Exceptions\IntentException; +use Discord\Repository\EmojiRepository; +use Discord\Repository\GuildRepository; +use Discord\Repository\AbstractRepository; +use Discord\WebSockets\Events\GuildCreate; use React\Socket\Connector as SocketConnector; +use Discord\Repository\PrivateChannelRepository; use Symfony\Component\OptionsResolver\OptionsResolver; -use function React\Async\coroutine; -use function React\Promise\all; - /** * The Discord client class. * @@ -78,7 +79,6 @@ * @property PrivateChannelRepository $private_channels * @property SoundRepository $sounds * @property UserRepository $users - */ class Discord { @@ -105,13 +105,6 @@ class Discord */ protected $logger; - /** - * An array of loggers for voice clients. - * - * @var ?LoggerInterface[] Loggers. - */ - protected $voiceLoggers = []; - /** * An array of options passed to the client. * @@ -189,13 +182,6 @@ class Discord */ protected $sessionId; - /** - * An array of voice clients that are currently connected. - * - * @var array Voice Clients. - */ - protected $voiceClients = []; - /** * An array of large guilds that need to be requested for members. * @@ -344,6 +330,13 @@ class Discord */ private $application_commands; + /** + * The voice handler, of clients and packets. + * + * @var Voice + */ + public Voice $voice; + /** * Creates a Discord client instance. * @@ -403,9 +396,9 @@ public function __construct(array $options = []) */ protected function handleVoiceServerUpdate(object $data): void { - if (isset($this->voiceClients[$data->d->guild_id])) { + if (isset($this->voice->clients[$data->d->guild_id])) { $this->logger->debug('voice server update received', ['guild' => $data->d->guild_id, 'data' => $data->d]); - $this->voiceClients[$data->d->guild_id]->handleVoiceServerChange((array) $data->d); + $this->voice->clients[$data->d->guild_id]->handleVoiceServerChange((array) $data->d); } } @@ -584,9 +577,9 @@ protected function handleGuildMembersChunk(object $data): void */ protected function handleVoiceStateUpdate(object $data): void { - if (isset($this->voiceClients[$data->d->guild_id])) { + if (isset($this->voice->clients[$data->d->guild_id])) { $this->logger->debug('voice state update received', ['guild' => $data->d->guild_id, 'data' => $data->d]); - $this->voiceClients[$data->d->guild_id]->handleVoiceStateUpdate($data->d); + $this->voice->clients[$data->d->guild_id]->handleVoiceStateUpdate($data->d); } } @@ -1120,7 +1113,7 @@ public function requestSoundboardSounds(array $guildIds): void * * @param array $data Packet data. */ - protected function send(array $data, bool $force = false): void + public function send(array $data, bool $force = false): void { // Wait until payload count has been reset // Keep 5 payloads for heartbeats as required @@ -1147,6 +1140,9 @@ protected function ready() } $this->emittedInit = true; + $this->voice = new Voice($this->ws, $this->loop, $this->logger, $this?->id ?? $this->client->id); + $this->logger->info('voice class initialized'); + $this->logger->info('client is ready'); $this->emit('init', [$this]); @@ -1208,12 +1204,13 @@ public function updatePresence(?Activity $activity = null, bool $idle = false, s * Gets a voice client from a guild ID. Returns null if there is no voice client. * * @param string $guild_id The guild ID to look up. + * @deprecated Use $discord->voice->getVoiceClient() * * @return VoiceClient|null */ public function getVoiceClient(string $guild_id): ?VoiceClient { - return $this->voiceClients[$guild_id] ?? null; + return $this->voice->clients[$guild_id] ?? null; } /** @@ -1229,95 +1226,11 @@ public function getVoiceClient(string $guild_id): ?VoiceClient * @since 10.0.0 Removed argument $check that has no effect (it is always checked) * @since 4.0.0 * - * @return PromiseInterface + * @return PromiseInterface */ public function joinVoiceChannel(Channel $channel, $mute = false, $deaf = true, ?LoggerInterface $logger = null): PromiseInterface { - $deferred = new Deferred(); - - if (! $channel->isVoiceBased()) { - $deferred->reject(new \RuntimeException('Channel must allow voice.')); - - return $deferred->promise(); - } - - if (isset($this->voiceClients[$channel->guild_id])) { - $deferred->reject(new \RuntimeException('You cannot join more than one voice channel per guild.')); - - return $deferred->promise(); - } - - $data = [ - 'user_id' => $this->id, - 'deaf' => $deaf, - 'mute' => $mute, - ]; - - $voiceStateUpdate = function ($vs, $discord) use ($channel, &$data, &$voiceStateUpdate) { - if ($vs->guild_id != $channel->guild_id) { - return; // This voice state update isn't for our guild. - } - - $data['session'] = $vs->session_id; - $this->logger->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $vs->session_id]); - $this->removeListener(Event::VOICE_STATE_UPDATE, $voiceStateUpdate); - }; - - $voiceServerUpdate = function ($vs, $discord) use ($channel, &$data, &$voiceServerUpdate, $deferred, $logger) { - if ($vs->guild_id != $channel->guild_id) { - return; // This voice server update isn't for our guild. - } - - $data['token'] = $vs->token; - $data['endpoint'] = $vs->endpoint; - $data['dnsConfig'] = $discord->options['dnsConfig']; - $this->logger->info('received token and endpoint for voice session', ['guild' => $channel->guild_id, 'token' => $vs->token, 'endpoint' => $vs->endpoint]); - - if (null === $logger) { - $logger = $this->logger; - } - - $vc = new VoiceClient($this->ws, $this->loop, $channel, $logger, $data); - - $vc->once('ready', function () use ($vc, $deferred, $channel, $logger) { - $logger->info('voice client is ready'); - $this->voiceClients[$channel->guild_id] = $vc; - - $vc->setBitrate($channel->bitrate); - $logger->info('set voice client bitrate', ['bitrate' => $channel->bitrate]); - $deferred->resolve($vc); - }); - $vc->once('error', function ($e) use ($deferred, $logger) { - $logger->error('error initializing voice client', ['e' => $e->getMessage()]); - $deferred->reject($e); - }); - $vc->once('close', function () use ($channel, $logger) { - $logger->warning('voice client closed'); - unset($this->voiceClients[$channel->guild_id]); - }); - - $vc->start(); - - $this->voiceLoggers[$channel->guild_id] = $logger; - $this->removeListener(Event::VOICE_SERVER_UPDATE, $voiceServerUpdate); - }; - - $this->on(Event::VOICE_STATE_UPDATE, $voiceStateUpdate); - $this->on(Event::VOICE_SERVER_UPDATE, $voiceServerUpdate); - - $payload = [ - 'op' => Op::OP_VOICE_STATE_UPDATE, - 'd' => [ - 'guild_id' => $channel->guild_id, - 'channel_id' => $channel->id, - 'self_mute' => $mute, - 'self_deaf' => $deaf, - ], - ]; - - $this->send($payload); - - return $deferred->promise(); + return $this->voice->createClientAndJoinChannel($channel, $this, $mute, $deaf,); } /** diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php new file mode 100644 index 000000000..12e9ad03e --- /dev/null +++ b/src/Discord/Voice/Voice.php @@ -0,0 +1,152 @@ + $clients + */ + public function __construct( + protected WebSocket $botWs, + protected LoopInterface $loop, + protected LoggerInterface $logger, + protected int $botId, + public array $clients = [], + ) { + } + + public function createClientAndJoinChannel( + Channel $channel, + Discord $discord, + bool $mute = false, + bool $deaf = true, + ) + { + $deferred = new Deferred(); + + if (! $channel->isVoiceBased()) { + $deferred->reject(new \RuntimeException('Channel must allow voice.')); + + return $deferred->promise(); + } + + if (isset($this->clients[$channel->guild_id])) { + $deferred->reject(new \RuntimeException('You cannot join more than one voice channel per guild.')); + + return $deferred->promise(); + } + + $this->clients[$channel->guild_id] = ['data' => []]; + $this->clients[$channel->guild_id]['data'] = [ + 'user_id' => $this->botId, + 'deaf' => $deaf, + 'mute' => $mute, + ]; + + $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); + $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); + + $discord->send([ + 'op' => Op::OP_VOICE_STATE_UPDATE, + 'd' => [ + 'guild_id' => $channel->guild_id, + 'channel_id' => $channel->id, + 'self_mute' => $mute, + 'self_deaf' => $deaf, + ], + ]); + + return $deferred->promise(); + } + + public function getClient(string $guildId): ?VoiceClient + { + if (! isset($this->clients[$guildId])) { + return null; + } + + return $this->clients[$guildId]; + } + + private function stateUpdate($state, $channel): void + { + if ($state->guild_id != $channel->guild_id) { + return; // This voice state update isn't for our guild. + } + + $this->clients[$channel->guild_id]['data']['session'] = $state->session_id; + $this->logger->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $state->session_id]); + } + + private function serverUpdate($state, Channel $channel, $discord, Deferred $deferred): void + { + if ($state->guild_id !== $channel->guild_id) { + return; // This voice server update isn't for our guild. + } + + $data = $this->clients[$channel->guild_id]['data']; + unset($this->clients[$channel->guild_id]['data']); + + $data['token'] = $state->token; + $data['endpoint'] = $state->endpoint; + $data['dnsConfig'] = $discord->options['dnsConfig']; + + $this->logger->info('received token and endpoint for voice session', [ + 'guild' => $channel->guild_id, + 'token' => $state->token, + 'endpoint' => $state->endpoint + ]); + + $client = new VoiceClient($this->botWs, $this->loop, $channel, $this->logger, $data); + + $client->once('ready', function () use ($client, $deferred, $channel) { + $this->logger->info('voice client is ready'); + $this->clients[$channel->guild_id] = $client; + + $client->setBitrate($channel->bitrate); + + $this->logger->info('set voice client bitrate', ['bitrate' => $channel->bitrate]); + $deferred->resolve($client); + }) + ->once('error', function ($e) use ($deferred) { + $this->logger->error('error initializing voice client', ['e' => $e->getMessage()]); + $deferred->reject($e); + }) + ->once('close', function () use ($channel) { + $this->logger->warning('voice client closed'); + unset($this->clients[$channel->guild_id]); + }) + ->start(); + } + + private function sendStateUpdate(Channel $channel, bool $mute = false, bool $deaf = true): void + { + $this->botWs->send(json_encode([ + 'op' => Op::OP_VOICE_STATE_UPDATE, + 'd' => [ + 'guild_id' => $channel->guild_id, + 'channel_id' => $channel->id, + 'self_mute' => $mute, + 'self_deaf' => $deaf, + ], + ])); + } +} diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index e2ecc696e..40f82a50d 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -28,6 +28,7 @@ use React\EventLoop\TimerInterface; use React\Promise\PromiseInterface; use Ratchet\RFC6455\Messaging\Message; +use Discord\Helpers\CollectionInterface; use React\Stream\ReadableResourceStream; use Discord\Helpers\Buffer as RealBuffer; use React\Stream\ReadableStreamInterface; @@ -39,7 +40,6 @@ use Discord\Exceptions\FFmpegNotFoundException; use Discord\Exceptions\LibSodiumNotFoundException; use React\Stream\ReadableResourceStream as Stream; - use Discord\Exceptions\Voice\ClientNotReadyException; use Discord\Exceptions\Voice\AudioAlreadyPlayingException; @@ -339,16 +339,6 @@ class VoiceClient extends EventEmitter public array $tempFiles; - /** - * Constructs the Voice Client instance. - * - * @param WebSocket $websocket The main WebSocket client. - * @param LoopInterface $loop The ReactPHP event loop. - * @param Channel $channel The channel we are connecting to. - * @param LoggerInterface $logger The logger. - * @param array $data More information related to the voice client. - */ - /** * Constructs the Voice client instance * @@ -420,10 +410,9 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->voiceWebsocket = $ws; - $firstPack = true; $ip = $port = ''; - $ws->on('message', function (Message $message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port): void { + $ws->on('message', function (Message $message) use ($udpfac, &$ip, &$port): void { $data = json_decode($message->getPayload()); $this->emit('ws-message', [$message, $this]); @@ -454,7 +443,7 @@ public function handleWebSocketConnection(WebSocket $ws): void } if (! $this->deaf && $this->secretKey) { - $this->client->on('message', fn (string $message, string $address, Socket $client) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->logger))); + $this->client->on('message', fn (string $message) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->logger))); } break; @@ -545,9 +534,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('connected to voice UDP'); $this->client = $client; - $this->loop->addTimer(0.1, function () use ($buffer) { - $this->client->send((string) $buffer); - }); + $this->loop->addTimer(0.1, fn () => $this->client->send($buffer->__toString())); $this->udpHeartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { $buffer = new Buffer(9); @@ -561,12 +548,9 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('sent UDP heartbeat'); }); - $client->on('error', function ($e): void { - $this->emit('udp-error', [$e]); - }); - - $decodeUDP = function ($message) use (&$decodeUDP, $client, &$ip, &$port): void { + $client->on('error', fn ($e): void => $this->emit('udp-error', [$e])); + $decodeUDP = function ($message) use (&$ip, &$port): void { /** * Unpacks the message into an array. * @@ -582,10 +566,6 @@ public function handleWebSocketConnection(WebSocket $ws): void */ $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); - # Commented out since it's not being used as of yet - # $typeRequest = $unpackedMessageArray['Type1']; - # $typeResponse = $unpackedMessageArray['Type2']; - # $length = $unpackedMessageArray['Length']; $this->ssrc = $unpackedMessageArray['SSRC']; $ip = $unpackedMessageArray['Address']; $port = $unpackedMessageArray['Port']; @@ -610,7 +590,6 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); }); - break; } default: @@ -1575,10 +1554,10 @@ protected function handleAudioData(VoicePacket $voicePacket): void private function monitorProcessExit(Process $process, $ss, callable $createDecoder): void { // Store the process ID - $pid = $process->getPid(); + // $pid = $process->getPid(); // Check every second if the process is still running - $timer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss, &$createDecoder, &$timer, $pid) { + $timer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss, &$createDecoder, &$timer) { // Check if the process is still running if (!$process->isRunning()) { // Get the exit code From f0850b944bd8ec37f4afb6b8244190cfc3904801 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:09:36 -0400 Subject: [PATCH 009/121] Reorganize imports --- src/Discord/Discord.php | 67 +++++++++++++++++++++-------------------- 1 file changed, 34 insertions(+), 33 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 304864f7a..1e920e736 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -11,53 +11,54 @@ namespace Discord; -use Monolog\Level; -use Discord\Http\Http; -use Discord\Parts\Part; -use Discord\Voice\Voice; -use React\EventLoop\Loop; -use Discord\Http\Endpoint; -use Discord\WebSockets\Op; -use Discord\Helpers\BigInt; -use React\Promise\Deferred; +use Discord\Exceptions\IntentException; use Discord\Factory\Factory; -use Discord\Parts\User\User; -use Psr\Log\LoggerInterface; -use Discord\WebSockets\Event; -use Ratchet\Client\Connector; -use Ratchet\Client\WebSocket; +use Discord\Helpers\CacheConfig; +use Discord\Helpers\BigInt; +use Discord\Helpers\RegisteredCommand; +use Discord\Http\Drivers\React; +use Discord\Http\Endpoint; +use Discord\Http\Http; +use Discord\Repository\AbstractRepository; +use Discord\Repository\EmojiRepository; +use Discord\Repository\GuildRepository; +use Discord\Repository\PrivateChannelRepository; +use Discord\Repository\UserRepository; +use Discord\Parts\Channel\Channel; use Discord\Parts\Guild\Guild; +use Discord\Parts\OAuth\Application; +use Discord\Parts\Part; +use Discord\Parts\User\Activity; use Discord\Parts\User\Client; use Discord\Parts\User\Member; +use Discord\Parts\User\User; +use Discord\Voice\Voice; use Discord\Voice\VoiceClient; -use Monolog\Logger as Monolog; -use Discord\Http\Drivers\React; -use Discord\WebSockets\Intents; -use function React\Promise\all; -use Discord\Helpers\CacheConfig; -use Discord\Parts\User\Activity; +use Discord\WebSockets\Event; +use Discord\WebSockets\Events\GuildCreate; use Discord\WebSockets\Handlers; +use Discord\WebSockets\Intents; +use Discord\WebSockets\Op; use Evenement\EventEmitterTrait; -use Discord\Parts\Channel\Channel; +use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; +use Monolog\Level; +use Monolog\Logger as Monolog; +use Psr\Log\LoggerInterface; +use Ratchet\Client\Connector; +use Ratchet\Client\WebSocket; +use Ratchet\RFC6455\Messaging\Message; +use React\EventLoop\Loop; use React\EventLoop\LoopInterface; -use function React\Async\coroutine; use React\EventLoop\TimerInterface; +use React\Promise\Deferred; use React\Promise\PromiseInterface; -use Discord\Parts\OAuth\Application; -use Monolog\Formatter\LineFormatter; -use Discord\Helpers\RegisteredCommand; -use Discord\Repository\UserRepository; -use Ratchet\RFC6455\Messaging\Message; -use Discord\Exceptions\IntentException; -use Discord\Repository\EmojiRepository; -use Discord\Repository\GuildRepository; -use Discord\Repository\AbstractRepository; -use Discord\WebSockets\Events\GuildCreate; use React\Socket\Connector as SocketConnector; -use Discord\Repository\PrivateChannelRepository; use Symfony\Component\OptionsResolver\OptionsResolver; +use function React\Async\coroutine; +use function React\Promise\all; + /** * The Discord client class. * From 99069d7ef9bc7c51b1fd6628fc549e891a1d4a45 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:11:10 -0400 Subject: [PATCH 010/121] Imports --- src/Discord/Voice/Voice.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index 12e9ad03e..a3dffef3e 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -2,16 +2,16 @@ namespace Discord\Voice; +use Evenement\EventEmitterTrait; use Discord\Discord; +use Discord\Parts\Channel\Channel; +use Discord\Voice\VoiceClient; +use Discord\WebSockets\Event; use Discord\WebSockets\Op; -use React\Promise\Deferred; use Psr\Log\LoggerInterface; -use Discord\WebSockets\Event; use Ratchet\Client\WebSocket; -use Discord\Voice\VoiceClient; -use Evenement\EventEmitterTrait; -use Discord\Parts\Channel\Channel; use React\EventLoop\LoopInterface; +use React\Promise\Deferred; final class Voice { From ee994b644788417e4a7487330d6b0a37e5834f52 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:17:51 -0400 Subject: [PATCH 011/121] Imports --- src/Discord/Voice/VoiceClient.php | 43 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 40f82a50d..546e62523 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -11,37 +11,36 @@ namespace Discord\Voice; -use Throwable; +use Discord\Exceptions\Voice\ClientNotReadyException; +use Discord\Exceptions\Voice\AudioAlreadyPlayingException; +use Discord\Helpers\Buffer as RealBuffer; +use Discord\Exceptions\FFmpegNotFoundException; +use Discord\Exceptions\LibSodiumNotFoundException; +use Discord\Helpers\Collection; +use Discord\Helpers\CollectionInterface; +use Discord\Exceptions\OutdatedDCAException; +use Discord\Exceptions\FileNotFoundException; +use Discord\Parts\Channel\Channel; +use Discord\Voice\VoicePacket; +use Discord\Voice\ReceiveStream; use Discord\WebSockets\Op; -use React\Datagram\Socket; use Evenement\EventEmitter; -use React\Promise\Deferred; use Psr\Log\LoggerInterface; -use React\Dns\Config\Config; +use Ratchet\Client\Connector as WsFactory; use Ratchet\Client\WebSocket; -use Discord\Voice\VoicePacket; -use Discord\Helpers\Collection; +use Ratchet\RFC6455\Messaging\Message; use React\ChildProcess\Process; -use Discord\Voice\ReceiveStream; -use Discord\Parts\Channel\Channel; +use React\Datagram\Factory as DatagramFactory; +use React\Datagram\Socket; +use React\Dns\Config\Config; +use React\Dns\Resolver\Factory as DNSFactory; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; +use React\Promise\Deferred; use React\Promise\PromiseInterface; -use Ratchet\RFC6455\Messaging\Message; -use Discord\Helpers\CollectionInterface; -use React\Stream\ReadableResourceStream; -use Discord\Helpers\Buffer as RealBuffer; -use React\Stream\ReadableStreamInterface; -use Ratchet\Client\Connector as WsFactory; -use Discord\Exceptions\OutdatedDCAException; -use Discord\Exceptions\FileNotFoundException; -use React\Dns\Resolver\Factory as DNSFactory; -use React\Datagram\Factory as DatagramFactory; -use Discord\Exceptions\FFmpegNotFoundException; -use Discord\Exceptions\LibSodiumNotFoundException; use React\Stream\ReadableResourceStream as Stream; -use Discord\Exceptions\Voice\ClientNotReadyException; -use Discord\Exceptions\Voice\AudioAlreadyPlayingException; +use React\Stream\ReadableStreamInterface; +use Throwable; /** * The Discord voice client. From e26a968a8e374b61e16fbf11290f33ab85971bdb Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:30:11 -0400 Subject: [PATCH 012/121] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e198e89d5..213be651a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ composer.lock phpunit.log /.phpunit* /coverage +/.vs From ae845caa8d458acb3bb55c357c23b7ee8a366a16 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:34:04 -0400 Subject: [PATCH 013/121] Import alias and broken function --- src/Discord/Voice/VoiceClient.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 546e62523..d194ae336 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -547,7 +547,10 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('sent UDP heartbeat'); }); - $client->on('error', fn ($e): void => $this->emit('udp-error', [$e])); + $client->on('error', function ($e): void + { + $this->emit('udp-error', [$e]); + }); $decodeUDP = function ($message) use (&$ip, &$port): void { /** @@ -839,7 +842,7 @@ public function playOggStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new ReadableResourceStream($stream, $this->loop); + $stream = new Stream($stream, $this->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -955,7 +958,7 @@ public function playDCAStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new ReadableResourceStream($stream, $this->loop); + $stream = new Stream($stream, $this->loop); } if (! ($stream instanceof ReadableStreamInterface)) { From b6509c3538ac326e07e49ae2816409adc5c59bfa Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 9 May 2025 10:50:08 -0400 Subject: [PATCH 014/121] VOICE_CLIENT_CONNECT deprecated Use VOICE_CLIENTS_CONNECT instead --- src/Discord/WebSockets/Op.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Discord/WebSockets/Op.php b/src/Discord/WebSockets/Op.php index f63ea9ac1..c913e8794 100644 --- a/src/Discord/WebSockets/Op.php +++ b/src/Discord/WebSockets/Op.php @@ -87,6 +87,7 @@ class Op public const VOICE_RESUMED = 9; /** One or more clients have connected to the voice channel */ public const VOICE_CLIENTS_CONNECT = 11; + public const VOICE_CLIENT_CONNECT = 11; // Deprecated, used VOICE_CLIENTS_CONNECT instead /** A client has disconnected from the voice channel. */ public const VOICE_CLIENT_DISCONNECT = 13; /** Was not documented within the op codes and statuses*/ From 4772049200b7ae61aaad0841e27a845f608652eb Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 16 May 2025 12:41:39 -0400 Subject: [PATCH 015/121] Imports --- src/Discord/Discord.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 1e920e736..b024cb67c 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -13,8 +13,8 @@ use Discord\Exceptions\IntentException; use Discord\Factory\Factory; -use Discord\Helpers\CacheConfig; use Discord\Helpers\BigInt; +use Discord\Helpers\CacheConfig; use Discord\Helpers\RegisteredCommand; use Discord\Http\Drivers\React; use Discord\Http\Endpoint; From e367550ae19df85436c89468efd99b3271d4bbbb Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 16:11:13 +0100 Subject: [PATCH 016/121] Updates usage of trafficcophp/bytebuffer to a locally ovewritten class Adds enum for format pack --- composer.json | 8 +- src/Discord/Helpers/FormatPackEnum.php | 170 +++++++++++++++ src/Discord/Voice/Buffer.php | 282 ++++++++++++++++++++++++- 3 files changed, 452 insertions(+), 8 deletions(-) create mode 100644 src/Discord/Helpers/FormatPackEnum.php diff --git a/composer.json b/composer.json index 45c6ef72c..5ddf1afb4 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "exan/pawl": "^0.4.4", "react/datagram": "^1.8", "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0", - "trafficcophp/bytebuffer": "^0.4", + "trafficcophp/bytebuffer": "^0.3", "monolog/monolog": "^2.1.1 || ^3.0", "react/event-loop": "^1.2", "ext-json": "*", @@ -41,12 +41,6 @@ "wyrihaximus/react-cache-redis": "^4.5", "symfony/cache": "^5.4" }, - "repositories": [ - { - "type": "github", - "url": "https://github.com/alexandre433/byte-buffer" - } - ], "autoload": { "files": [ "src/Discord/functions.php" diff --git a/src/Discord/Helpers/FormatPackEnum.php b/src/Discord/Helpers/FormatPackEnum.php new file mode 100644 index 000000000..c8d4cef03 --- /dev/null +++ b/src/Discord/Helpers/FormatPackEnum.php @@ -0,0 +1,170 @@ + 2, + self::N, self::V => 4, + self::c, self::C => 1, + default => throw new \InvalidArgumentException('Invalid format pack'), + }; + } +} diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 956bfa37f..2abf603c7 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -12,8 +12,9 @@ namespace Discord\Voice; use ArrayAccess; +use SplFixedArray; +use Discord\Helpers\FormatPackEnum; use TrafficCophp\ByteBuffer\Buffer as BaseBuffer; -use TrafficCophp\ByteBuffer\FormatPackEnum; /** * A Byte Buffer similar to Buffer in NodeJS. @@ -22,6 +23,285 @@ */ class Buffer extends BaseBuffer implements ArrayAccess { + protected SplFixedArray $buffer; + + public function __construct($argument) + { + match (true) { + is_string($argument) => $this->initializeStructs(strlen($argument), $argument), + is_int($argument) => $this->initializeStructs($argument, pack(FormatPackEnum::x->value . "$argument")), + default => throw new \InvalidArgumentException('Constructor argument must be an binary string or integer') + }; + } + + public function __toString(): string + { + $buf = ''; + foreach ($this->buffer as $bytes) { + $buf .= $bytes; + } + return $buf; + } + + public static function make($argument): static + { + return new static($argument); + } + + protected function initializeStructs($length, $content): void + { + $this->buffer = new SplFixedArray($length); + for ($i = 0; $i < $length; $i++) { + $this->buffer[$i] = $content[$i]; + } + } + + /** + * Inserts a value into the buffer at the specified offset. + * + * @param FormatPackEnum|string $format + * @param mixed $value + * @param int $offset + * @param mixed $length + * @return Buffer + */ + protected function insert($format, $value, $offset, $length): self + { + $bytes = pack($format?->value ?? $format, $value); + + if (null === $length) { + $length = strlen($bytes); + } + + for ($i = 0; $i < strlen($bytes); $i++) { + $this->buffer[$offset++] = $bytes[$i]; + } + + return $this; + } + + /** + * Extracts a value from the buffer at the specified offset. + * + * @param FormatPackEnum|string $format + * @param int $offset + * @param int $length + * @return mixed + */ + protected function extract($format, $offset, $length) + { + $encoded = ''; + for ($i = 0; $i < $length; $i++) { + $encoded .= $this->buffer->offsetGet($offset + $i); + } + + if ($format == FormatPackEnum::N && PHP_INT_SIZE <= 4) { + [, $h, $l] = unpack('n*', $encoded); + $result = $l + $h * 0x010000; + } elseif ($format == FormatPackEnum::V && PHP_INT_SIZE <= 4) { + [, $h, $l] = unpack('v*', $encoded); + $result = $h + $l * 0x010000; + } else { + [, $result] = unpack($format?->value ?? $format, $encoded); + } + + return $result; + } + + /** + * Checks if the actual value exceeds the expected maximum size. + * + * @param mixed $excpectedMax + * @param mixed $actual + * @throws \InvalidArgumentException + * @return static + */ + protected function checkForOverSize($excpectedMax, $actual) + { + if ($actual > $excpectedMax) { + throw new \InvalidArgumentException(sprintf('%d exceeded limit of %d', $actual, $excpectedMax)); + } + + return $this; + } + + public function length(): int + { + return $this->buffer->getSize(); + } + + public function getLastEmptyPosition(): int + { + foreach($this->buffer as $key => $value) { + if (empty(trim($value))) { + return $key; + } + } + + return 0; + } + + /** + * Writes a string to the buffer at the specified offset. + * + * @param string $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function write($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $length = strlen($value); + $this->insert('a' . $length, $value, $offset, $length); + + return $this; + } + + /** + * Writes an 8-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt8($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::C; + $this->checkForOverSize(0xff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 16-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt16BE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::n; + $this->checkForOverSize(0xffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 16-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt16LE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::v; + $this->checkForOverSize(0xffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 32-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt32BE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::N; + $this->checkForOverSize(0xffffffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 32-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt32LE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::V; + $this->checkForOverSize(0xffffffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Reads a string from the buffer at the specified offset. + * + * @param int $offset The offset to read from. + * @param int $length The length of the string to read. + * @return string The data read. + */ + public function read($offset, $length) + { + return $this->extract('a' . $length, $offset, $length); + } + + public function readInt8($offset) + { + $format = FormatPackEnum::C; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt16BE($offset) + { + $format = FormatPackEnum::n; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt16LE($offset) + { + $format = FormatPackEnum::v; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt32BE($offset) + { + $format = FormatPackEnum::N; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt32LE($offset) + { + $format = FormatPackEnum::V; + return $this->extract($format, $offset, $format->getLength()); + } + /** * Writes a 32-bit unsigned integer with big endian. * From 2b227c0a27d088035ccbe2ee76ebc46fb8f9a49f Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 16:24:37 +0100 Subject: [PATCH 017/121] Updates function according to master change --- src/Discord/Voice/Voice.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index a3dffef3e..62eaa5264 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -2,6 +2,7 @@ namespace Discord\Voice; +use Discord\WebSockets\Payload; use Evenement\EventEmitterTrait; use Discord\Discord; use Discord\Parts\Channel\Channel; @@ -64,15 +65,15 @@ public function createClientAndJoinChannel( $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); - $discord->send([ - 'op' => Op::OP_VOICE_STATE_UPDATE, - 'd' => [ + $discord->send(Payload::new( + Op::OP_VOICE_STATE_UPDATE, + [ 'guild_id' => $channel->guild_id, 'channel_id' => $channel->id, 'self_mute' => $mute, 'self_deaf' => $deaf, ], - ]); + )); return $deferred->promise(); } From a2619b02ae6942b3900572a115759c4b5eb2f042 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 11 Mar 2025 16:48:36 +0000 Subject: [PATCH 018/121] WIP --- src/Discord/Voice/VoiceClient.php | 472 +++++++++++++++++++----------- 1 file changed, 297 insertions(+), 175 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 9db4fe497..4d46f40b7 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -13,30 +13,37 @@ namespace Discord\Voice; -use Discord\Exceptions\FFmpegNotFoundException; -use Discord\Exceptions\FileNotFoundException; -use Discord\Exceptions\LibSodiumNotFoundException; -use Discord\Exceptions\OutdatedDCAException; -use Discord\Helpers\Buffer as RealBuffer; -use Discord\Helpers\Collection; -use Discord\Parts\Channel\Channel; -use Discord\WebSockets\Payload; +use Throwable; use Discord\WebSockets\Op; -use Evenement\EventEmitter; -use Ratchet\Client\Connector as WsFactory; -use Ratchet\Client\WebSocket; -use React\Datagram\Factory as DatagramFactory; use React\Datagram\Socket; -use React\Dns\Resolver\Factory as DNSFactory; -use React\EventLoop\LoopInterface; +use Evenement\EventEmitter; +use React\Promise\Deferred; use Psr\Log\LoggerInterface; +use React\Dns\Config\Config; +use Ratchet\Client\WebSocket; +use Discord\Parts\User\Member; +use Discord\Voice\VoicePacket; +use Discord\Helpers\Collection; +use Discord\WebSockets\Payload; use React\ChildProcess\Process; -use React\Promise\Deferred; -use React\Promise\PromiseInterface; -use React\Stream\ReadableResourceStream as Stream; +use Discord\Parts\Channel\Channel; +use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; +use React\Promise\PromiseInterface; +use Ratchet\RFC6455\Messaging\Message; use React\Stream\ReadableResourceStream; +use Discord\Helpers\Buffer as RealBuffer; use React\Stream\ReadableStreamInterface; +use Ratchet\Client\Connector as WsFactory; +use Discord\Exceptions\OutdatedDCAException; +use Discord\Exceptions\FileNotFoundException; +use React\Dns\Resolver\Factory as DNSFactory; +use React\Datagram\Factory as DatagramFactory; +use Discord\Exceptions\FFmpegNotFoundException; +use Discord\Exceptions\LibSodiumNotFoundException; +use React\Stream\ReadableResourceStream as Stream; +use Discord\Exceptions\Voice\ClientNotReadyException; +use Discord\Exceptions\Voice\AudioAlreadyPlayingException; /** * The Discord voice client. @@ -57,28 +64,28 @@ class VoiceClient extends EventEmitter * * @var string The silence frame. */ - public const SILENCE_FRAME = "\xF8\xFF\xFE"; + public const SILENCE_FRAME = "\0xF8\0xFF\0xFE"; /** * Is the voice client ready? * * @var bool Whether the voice client is ready. */ - protected $ready = false; + protected bool $ready = false; /** * The DCA binary name that we will use. * * @var string The DCA binary name that will be run. */ - protected $dca; + protected ?string $dca; /** * The FFmpeg binary location. * * @var string */ - protected $ffmpeg; + protected ?string $ffmpeg; /** * The ReactPHP event loop. @@ -92,68 +99,68 @@ class VoiceClient extends EventEmitter * * @var WebSocket The main WebSocket client. */ - protected $mainWebsocket; + protected ?WebSocket $mainWebsocket; /** * The voice WebSocket instance. * * @var WebSocket The voice WebSocket client. */ - protected $voiceWebsocket; + protected ?WebSocket $voiceWebsocket; /** * The UDP client. * * @var Socket The voiceUDP client. */ - public $client; + public ?Socket $client; /** * The Channel that we are connecting to. * * @var Channel The channel that we are going to connect to. */ - protected $channel; + protected ?Channel $channel; /** * Data from the main WebSocket. * * @var array Information required for the voice WebSocket. */ - protected $data; + protected ?array $data; /** * The Voice WebSocket endpoint. * * @var string The endpoint the Voice WebSocket and UDP client will connect to. */ - protected $endpoint; + protected ?string $endpoint; /** * The port the UDP client will use. * * @var int The port that the UDP client will connect to. */ - protected $udpPort; + protected ?int $udpPort; /** * The UDP heartbeat interval. * * @var int How often we send a heartbeat packet. */ - protected $heartbeat_interval; + protected ?int $heartbeatInterval; /** * The Voice WebSocket heartbeat timer. * - * @var TimerInterface The heartbeat periodic timer. + * @var TimerInterface|null The heartbeat periodic timer. */ protected $heartbeat; /** * The UDP heartbeat timer. * - * @var TimerInterface The heartbeat periodic timer. + * @var TimerInterface|null The heartbeat periodic timer. */ protected $udpHeartbeat; @@ -162,98 +169,106 @@ class VoiceClient extends EventEmitter * * @var int The heartbeat sequence. */ - protected $heartbeatSeq = 0; + protected int $heartbeatSeq = 0; /** * The SSRC value. * * @var int The SSRC value used for RTP. */ - public $ssrc; + public ?int $ssrc; /** * The sequence of audio packets being sent. * * @var int The sequence of audio packets. */ - protected $seq = 0; + protected int $seq = 0; /** * The timestamp of the last packet. * * @var int The timestamp the last packet was constructed. */ - protected $timestamp = 0; + protected int $timestamp = 0; /** * The Voice WebSocket mode. * + * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes * @var string The voice mode. */ - protected $mode = 'xsalsa20_poly1305'; + protected string $mode = 'aead_xchacha20_poly1305_rtpsize'; /** * The secret key used for encrypting voice. * * @var string The secret key. */ - protected $secret_key; + protected ?string $secretKey; + + /** + * The raw secret key + * + * @var array + */ + protected ?array $rawKey; /** * Are we currently set as speaking? * * @var bool Whether we are speaking or not. */ - protected $speaking = false; + protected bool $speaking = false; /** * Whether we are set as mute. * * @var bool Whether we are set as mute. */ - protected $mute = false; + protected bool $mute = false; /** * Whether we are set as deaf. * * @var bool Whether we are set as deaf. */ - protected $deaf = false; + protected bool $deaf = false; /** * Whether the voice client is currently paused. * * @var bool Whether the voice client is currently paused. */ - protected $paused = false; + protected bool $paused = false; /** * Have we sent the login frame yet? * * @var bool Whether we have sent the login frame. */ - protected $sentLoginFrame = false; + protected bool $sentLoginFrame = false; /** * The time we started sending packets. * * @var int The time we started sending packets. */ - protected $startTime; + protected ?int $startTime; /** * The stream time of the last packet. * * @var int The time we sent the last packet. */ - protected $streamTime = 0; + protected int $streamTime = 0; /** * The size of audio frames, in milliseconds. * * @var int The size of audio frames. */ - protected $frameSize = 20; + protected int $frameSize = 20; /** * Collection of the status of people speaking. @@ -274,14 +289,14 @@ class VoiceClient extends EventEmitter * * @var array Voice audio recieve streams. */ - protected $recieveStreams; + protected ?array $recieveStreams; /** * The volume the audio will be encoded with. * * @var int The volume that the audio will be encoded in. */ - protected $volume = 100; + protected int $volume = 100; /** * The audio application to encode with. @@ -290,33 +305,33 @@ class VoiceClient extends EventEmitter * * @var string The audio application. */ - protected $audioApplication = 'audio'; + protected string $audioApplication = 'audio'; /** * The bitrate to encode with. * * @var int Encoding bitrate. */ - protected $bitrate = 128000; + protected int $bitrate = 128000; /** * Is the voice client reconnecting? * * @var bool Whether the voice client is reconnecting. */ - protected $reconnecting = false; + protected bool $reconnecting = false; /** * Is the voice client being closed by user? * * @var bool Whether the voice client is being closed by user. */ - protected $userClose = false; + protected bool $userClose = false; /** * The logger. * - * @var LoggerInterface Logger. + * @var LoggerInterface|null Logger. */ protected $logger; @@ -325,21 +340,21 @@ class VoiceClient extends EventEmitter * * @var int Voice version. */ - protected $version = 4; + protected int $version = 8; /** * The Config for DNS Resolver. * * @var string|\React\Dns\Config\Config */ - protected $dnsConfig; + protected null|string|Config $dnsConfig; /** * Silence Frame Remain Count. * * @var int Amount of silence frames remaining. */ - protected $silenceRemaining = 5; + protected int $silenceRemaining = 5; /** * readopus Timer. @@ -353,7 +368,14 @@ class VoiceClient extends EventEmitter * * @var RealBuffer The Audio Buffer */ - protected $buffer; + protected ?RealBuffer $buffer; + + /** + * Current clients connected to the voice chat + * + * @var array + */ + public array $clientsConnected = []; /** * Constructs the Voice Client instance. @@ -424,113 +446,36 @@ public function handleWebSocketConnection(WebSocket $ws): void $firstPack = true; $ip = $port = ''; - $discoverUdp = function ($message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port) { - $data = json_decode($message->getPayload()); - - if ($data->op == Op::VOICE_READY) { - $ws->removeListener('message', $discoverUdp); - - $this->udpPort = $data->d->port; - $this->ssrc = $data->d->ssrc; - - $this->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); - - $buffer = new Buffer(74); - $buffer[1] = "\x01"; - $buffer[3] = "\x46"; - $buffer->writeUInt32BE($this->ssrc, 4); - /** @var PromiseInterface */ - $promise = $udpfac->createClient("{$data->d->ip}:{$this->udpPort}"); - - $promise->then(function (Socket $client) use (&$ws, &$firstPack, &$ip, &$port, $buffer) { - $this->logger->debug('connected to voice UDP'); - $this->client = $client; - - $this->loop->addTimer(0.1, function () use (&$client, $buffer) { - $client->send((string) $buffer); - }); - - $this->udpHeartbeat = $this->loop->addPeriodicTimer(5, function () use ($client) { - $buffer = new Buffer(9); - $buffer[0] = "\xC9"; - $buffer->writeUInt64LE($this->heartbeatSeq, 1); - ++$this->heartbeatSeq; - - $client->send((string) $buffer); - $this->emit('udp-heartbeat', []); - }); - - $client->on('error', function ($e) { - $this->emit('udp-error', [$e]); - }); - - $decodeUDP = function ($message) use (&$decodeUDP, $client, &$ip, &$port) { - $message = (string) $message; - // let's get our IP - $ip_start = 8; - $ip = substr($message, $ip_start); - $ip_end = strpos($ip, "\x00"); - $ip = substr($ip, 0, $ip_end); - - // now the port! - $port = substr($message, strlen($message) - 2); - $port = unpack('v', $port)[1]; - - $this->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); - - $payload = Payload::new( - Op::VOICE_SELECT_PROTO, - [ - 'protocol' => 'udp', - 'data' => [ - 'address' => $ip, - 'port' => (int) $port, - 'mode' => $this->mode, - ], - ] - ); - - $this->send($payload); + $ws->on('message', function (Message $message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port): void { + if ($message?->getPayload() === null || json_decode($message->getPayload() ?? '') === null) { - $client->removeListener('message', $decodeUDP); - - if (! $this->deaf) { - $client->on('message', [$this, 'handleAudioData']); - } - }; - - $client->on('message', $decodeUDP); - }, function ($e) { - $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); - $this->emit('error', [$e]); - }); + dd($message); + return; } - }; - $ws->on('message', $discoverUdp); - $ws->on('message', function ($message) { + $data = json_decode($message->getPayload()); + echo 'Received: ' . json_encode($data) . PHP_EOL; + $this->emit('ws-message', [$message, $this]); switch ($data->op) { case Op::VOICE_HEARTBEAT_ACK: // keepalive response $end = microtime(true); - $start = $data->d; + $start = $data->d->t; $diff = ($end - $start) * 1000; $this->logger->debug('received heartbeat ack', ['response_time' => $diff]); $this->emit('ws-ping', [$diff]); - $this->emit('ws-heartbeat-ack', [$data->d]); + $this->emit('ws-heartbeat-ack', [$data->d->t]); break; case Op::VOICE_DESCRIPTION: // ready $this->ready = true; $this->mode = $data->d->mode; - $this->secret_key = ''; - - foreach ($data->d->secret_key as $part) { - $this->secret_key .= pack('C*', $part); - } + $this->secretKey = ''; + $this->rawKey = $data->d->secret_key; + $this->secretKey = implode('', array_map(fn ($value) => pack('C', $value), $this->rawKey)); $this->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); @@ -542,13 +487,13 @@ public function handleWebSocketConnection(WebSocket $ws): void } break; - case Op::VOICE_SPEAKING: // user started speaking + case Op::VOICE_SPEAKING: // currently connected users $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); $this->speakingStatus[$data->d->ssrc] = $data->d; break; case Op::VOICE_HELLO: - $this->heartbeat_interval = $data->d->heartbeat_interval; + $this->heartbeatInterval = $data->d->heartbeat_interval; $sendHeartbeat = function () { $this->send(Payload::new( @@ -560,7 +505,19 @@ public function handleWebSocketConnection(WebSocket $ws): void }; $sendHeartbeat(); - $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeat_interval / 1000, $sendHeartbeat); + $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, $sendHeartbeat); + break; + case Op::VOICE_CLIENT_CONNECT: + # "d" contains an array with ['user_ids' => array] + $this->clientsConnected[] = $data->d->user_ids; + break; + case Op::VOICE_CLIENT_UNKNOWN_15: + case Op::VOICE_CLIENT_UNKNOWN_18: + $this->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + break; + case Op::VOICE_CLIENT_PLATFORM: + # handlePlatformPerUser + # platform = 0 assumed to be Desktop break; case Op::VOICE_DAVE_PREPARE_TRANSITION: $this->handleDavePrepareTransition($data); @@ -595,10 +552,104 @@ public function handleWebSocketConnection(WebSocket $ws): void case Op::VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME: $this->handleDaveMlsInvalidCommitWelcome($data); break; + + case Op::VOICE_READY: { + $this->udpPort = $data->d->port; + $this->ssrc = $data->d->ssrc; + + $this->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + + $buffer = new Buffer(74); + $buffer[1] = "\x01"; + $buffer[3] = "\x46"; + $buffer->writeUInt32BE($this->ssrc, 4); + /** @var PromiseInterface */ + $udpfac->createClient("{$data->d->ip}:{$this->udpPort}")->then(function (Socket $client) use (&$ip, &$port, $buffer): void { + $this->logger->debug('connected to voice UDP'); + $this->client = $client; + + $this->loop->addTimer(1, function () use ($buffer) { + $this->client->send((string) $buffer); + }); + + $this->udpHeartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { + $buffer = new Buffer(9); + $buffer[0] = 0xC9; + $buffer->writeUInt64LE($this->heartbeatSeq, 1); + ++$this->heartbeatSeq; + + $this->client->send($buffer->__toString()); + $this->emit('udp-heartbeat', []); + + $this->logger->debug('sent UDP heartbeat'); + }); + + $client->on('error', function ($e): void { + $this->emit('udp-error', [$e]); + }); + + $decodeUDP = function ($message) use (&$decodeUDP, $client, &$ip, &$port): void { + + /** + * Unpacks the message into an array. + * + * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively + * n (unsigned short) | Length | 2 bytes | Length of the following data + * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender + * A64 (string) | Address | 64 bytes | The IP address of the sender + * n (unsigned short) | Port | 2 bytes | The port of the sender + * + * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery + * @see https://www.php.net/manual/en/function.unpack.php + * @see https://www.php.net/manual/en/function.pack.php For the formats + */ + $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); + + # Commented out since it's not being used as of yet + # $typeRequest = $unpackedMessageArray['Type1']; + # $typeResponse = $unpackedMessageArray['Type2']; + # $length = $unpackedMessageArray['Length']; + $this->ssrc = $unpackedMessageArray['SSRC']; + $ip = $unpackedMessageArray['Address']; + $port = $unpackedMessageArray['Port']; + + $this->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + + $this->send([ + 'op' => Op::VOICE_SELECT_PROTO, + 'd' => [ + 'protocol' => 'udp', + 'data' => [ + 'address' => $ip, + 'port' => $port, + 'mode' => $this->mode, + ], + ], + ]); + + $client->removeListener('message', $decodeUDP); + + if (! $this->deaf) { + $client->on('message', [$this, 'handleAudioData']); + } + }; + + $client->on('message', $decodeUDP); + }, function (Throwable $e): void { + $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); + $this->emit('error', [$e]); + }); + + break; + } + default: + echo 'Unknown opcode: ' . $data->op . PHP_EOL; + echo 'Data: ' . json_encode($data) . PHP_EOL; + break; } }); - $ws->on('error', function ($e) { + $ws->on('error', function ($e): void { $this->logger->error('error with voice websocket', ['e' => $e->getMessage()]); $this->emit('ws-error', [$e]); }); @@ -645,6 +696,8 @@ public function handleWebSocketClose(int $op, string $reason): void $this->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); $this->emit('ws-close', [$op, $reason, $this]); + $this->clientsConnected = []; + // Cancel heartbeat timers if (null !== $this->heartbeat) { $this->loop->cancelTimer($this->heartbeat); @@ -657,7 +710,8 @@ public function handleWebSocketClose(int $op, string $reason): void } // Close UDP socket. - if ($this->client) { + if (isset($this->client)) { + $this->logger->warning('closing UDP client'); $this->client->close(); } @@ -669,7 +723,7 @@ public function handleWebSocketClose(int $op, string $reason): void $this->logger->warning('reconnecting in 2 seconds'); // Retry connect after 2 seconds - $this->loop->addTimer(2, function () { + $this->loop->addTimer(2, function (): void { $this->reconnecting = true; $this->sentLoginFrame = false; @@ -723,21 +777,22 @@ public function handleVoiceServerChange(array $data = []): void public function playFile(string $file, int $channels = 2): PromiseInterface { $deferred = new Deferred(); + $notAValidFile = filter_var($file, FILTER_VALIDATE_URL) === false && ! file_exists($file); - if (filter_var($file, FILTER_VALIDATE_URL) === false && ! file_exists($file)) { - $deferred->reject(new FileNotFoundException("Could not find the file \"{$file}\".")); - - return $deferred->promise(); - } - - if (! $this->ready) { - $deferred->reject(new \RuntimeException('Voice Client is not ready.')); + if ( + $notAValidFile || (! $this->ready) || $this->speaking + ) { + if ($notAValidFile) { + $deferred->reject(new FileNotFoundException("Could not find the file \"{$file}\".")); + } - return $deferred->promise(); - } + if (! $this->ready) { + $deferred->reject(new ClientNotReadyException()); + } - if ($this->speaking) { - $deferred->reject(new \RuntimeException('Audio already playing.')); + if ($this->speaking) { + $deferred->reject(new AudioAlreadyPlayingException()); + } return $deferred->promise(); } @@ -1058,10 +1113,10 @@ private function sendBuffer(string $data): void return; } - $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secret_key); + $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secretKey); $this->client->send((string) $packet); - $this->streamTime = microtime(true); + $this->streamTime = (int) microtime(true); $this->emit('packet-sent', [$packet]); } @@ -1088,6 +1143,7 @@ public function setSpeaking(bool $speaking = true): void [ 'speaking' => $speaking, 'delay' => 0, + 'ssrc' => $this->ssrc, ], )); @@ -1327,7 +1383,7 @@ public function close(): void $this->client->close(); $this->voiceWebsocket->close(); - $this->heartbeat_interval = null; + $this->heartbeatInterval = null; if (null !== $this->heartbeat) { $this->loop->cancelTimer($this->heartbeat); @@ -1419,9 +1475,9 @@ public function handleVoiceStateUpdate(object $data): void * * @param int|string $id Either a SSRC or User ID. * - * @return RecieveStream + * @return null|RecieveStream|ReceiveStream */ - public function getRecieveStream($id): ?RecieveStream + public function getRecieveStream($id): null|RecieveStream|ReceiveStream { if (isset($this->recieveStreams[$id])) { return $this->recieveStreams[$id]; @@ -1443,16 +1499,78 @@ public function getRecieveStream($id): ?RecieveStream */ protected function handleAudioData(string $message): void { - $voicePacket = VoicePacket::make($message); - $nonce = new Buffer(24); - $nonce->write($voicePacket->getHeader(), 0); - $message = \sodium_crypto_secretbox_open($voicePacket->getData(), (string) $nonce, $this->secret_key); + # echo 'Handling audio data' . PHP_EOL; + # echo 'Message: ' . $message . PHP_EOL; + - if ($message === false) { - // if we can't decode the message, drop it silently. + + ##### NON AI CODE DOWN HERE ######### + + $voicePacket = new VoicePacket(); + // $voicePacket->unpack($message); + + + // Supondo que $message contenha a mensagem completa e $this->secretKey esteja definida. + $len = strlen($message); + + // 1. Calcular o tamanho do header (12 bytes ou 16 se o bit de extensão estiver setado) + $headerSize = 12; + $firstByte = ord($message[0]); + if (($firstByte >> 4) & 0x01) { + $headerSize += 4; + } + + // 2. Extrair o header + $header = substr($message, 0, $headerSize); + + // 3. Preparar o nonce: pegar os últimos 4 bytes e preencher à esquerda com zeros + $nonce = substr($message, $len - 4, 4); + $nonceBuffer = str_pad($nonce, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, "\0", STR_PAD_RIGHT); + + // 4. Extrair o ciphertext e a tag de autenticação + // A mensagem: [header][ciphertext][auth tag][nonce] + // O tamanho do ciphertext é: total - headerSize - 16 (auth tag) - 4 (nonce) + $encryptedLength = $len - $headerSize - 16 - 4; + $cipherText = substr($message, $headerSize, $encryptedLength); + $authTag = substr($message, $headerSize + $encryptedLength, 16); + + // Concatenar ciphertext e auth tag, como na versão JS + $combined = $cipherText . $authTag; + + try { + // 5. Decriptar a mensagem usando o sodium + $resultMessage = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( + $combined, + $header, + $nonceBuffer, + $this->secretKey + ); + + if ($resultMessage === false) { + // Se a decriptação falhar, log o erro e retorne + $this->logger->error('Failed to decode voice packet.', [ + 'padded_nonce' => $nonceBuffer, + 'message_len' => $len, + ]); + return; + } + + // 6. Verificar e remover a extensão, se presente + if (substr($message, 12, 2) === "\xBE\xDE") { + // Lê os 2 bytes após o identificador da extensão para obter o tamanho da extensão + $extLengthData = substr($message, 14, 2); + $headerExtensionLength = unpack('n', $extLengthData)[1]; + // Remove 4 * headerExtensionLength bytes do início do resultado decriptado + $resultMessage = substr($resultMessage, 4 * $headerExtensionLength); + } + } catch (\Exception $e) { + $this->logger->error('Exception occurred when decoding voice packet: ' . $e->getMessage()); return; } + dd('Success!', $resultMessage); + + $message = $resultMessage; $this->emit('raw', [$message, $this]); $vp = VoicePacket::make($voicePacket->getHeader().$message); @@ -1461,6 +1579,7 @@ protected function handleAudioData(string $message): void if (null === $ss) { // for some reason we don't have a speaking status + $this->logger->warning('Received voice packet from unknown SSRC.', ['ssrc' => $vp->getSSRC()]); return; } @@ -1470,10 +1589,12 @@ protected function handleAudioData(string $message): void $this->recieveStreams[$ss->ssrc] = new RecieveStream(); $this->recieveStreams[$ss->ssrc]->on('pcm', function ($d) { + echo 'PCM: ' . $d . PHP_EOL; $this->emit('channel-pcm', [$d, $this]); }); $this->recieveStreams[$ss->ssrc]->on('opus', function ($d) { + echo 'OPUS: ' . $d . PHP_EOL; $this->emit('channel-opus', [$d, $this]); }); } @@ -1483,6 +1604,7 @@ protected function handleAudioData(string $message): void $decoder->start($this->loop); $decoder->stdout->on('data', function ($data) use ($ss) { + echo 'Data: ' . $data . PHP_EOL; $this->recieveStreams[$ss->ssrc]->writePCM($data); }); $decoder->stderr->on('data', function ($data) use ($ss) { @@ -1780,7 +1902,7 @@ public function getChannel(): Channel * * @link https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation */ - private function insertSilence(): void + public function insertSilence(): void { while (--$this->silenceRemaining > 0) { $this->sendBuffer(self::SILENCE_FRAME); From d5116570ea05a0c5f92cbb8557d814c81022d625 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 16:51:32 +0000 Subject: [PATCH 019/121] Updates the byte version, to the latest (forked by me) one --- composer.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 5fa90f497..ffc50b481 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "ratchet/pawl": "^0.4.3", "react/datagram": "^1.8", "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0", - "trafficcophp/bytebuffer": "^0.3", + "trafficcophp/bytebuffer": "^0.4", "monolog/monolog": "^2.1.1 || ^3.0", "react/event-loop": "^1.2", "ext-json": "*", @@ -42,6 +42,12 @@ "symfony/cache": "^5.4", "laravel/pint": "^1.21" }, + "repositories": [ + { + "type": "github", + "url": "https://github.com/alexandre433/byte-buffer" + } + ], "autoload": { "files": [ "src/Discord/functions.php" From 5cdc95b764febe163a37bb15673764674a692f1c Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 16:52:31 +0000 Subject: [PATCH 020/121] Adds a new class to rename the previous one "RecieveStream" to "ReceiveStream" --- src/Discord/Voice/ReceiveStream.php | 286 ++++++++++++++++++++++++++++ src/Discord/Voice/RecieveStream.php | 264 +------------------------ 2 files changed, 288 insertions(+), 262 deletions(-) create mode 100644 src/Discord/Voice/ReceiveStream.php diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php new file mode 100644 index 000000000..84be35183 --- /dev/null +++ b/src/Discord/Voice/ReceiveStream.php @@ -0,0 +1,286 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Voice; + +use Evenement\EventEmitter; +use React\Stream\DuplexStreamInterface; +use React\Stream\WritableStreamInterface; + +/** + * Handles recieving audio from Discord. + * + * @since 10.5.0 The class was renamed to ReceiveStream. + * @since 3.2.0 + */ +class ReceiveStream extends EventEmitter implements DuplexStreamInterface +{ + /** + * Contains PCM data. + * + * @var string PCM data. + */ + protected $pcmData = ''; + + /** + * Contains Opus data. + * + * @var string Opus data. + */ + protected $opusData = ''; + + /** + * Is the stream paused? + * + * @var bool Whether the stream is paused. + */ + protected $isPaused; + + /** + * Whether the stream is closed. + * + * @var bool Whether the stream is closed. + */ + protected $isClosed = false; + + /** + * The PCM pause buffer. + * + * @var array The PCM pause buffer. + */ + protected $pcmPauseBuffer = []; + + /** + * The pause buffer. + * + * @var array The pause buffer. + */ + protected $opusPauseBuffer = []; + + /** + * Constructs a stream. + */ + public function __construct() + { + // empty for now + } + + /** + * Writes PCM audio data. + * + * @param string $pcm PCM audio data. + */ + public function writePCM(string $pcm): void + { + echo 'called pcm'; + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->pcmPauseBuffer[] = $pcm; + + return; + } + + $this->pcmData .= $pcm; + + $this->emit('pcm', [$pcm]); + } + + /** + * Writes Opus audio data. + * + * @param string $opus Opus audio data. + */ + public function writeOpus(string $opus): void + { + echo 'called opus'; + + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->opusPauseBuffer[] = $opus; + + return; + } + + $this->opusData .= $opus; + + $this->emit('opus', [$opus]); + } + + /** + * {@inheritDoc} + */ + public function isReadable() + { + return !$this->isPaused && !$this->isClosed; + } + + /** + * {@inheritDoc} + */ + public function isWritable() + { + return $this->isReadable(); + } + + /** + * {@inheritDoc} + */ + public function write($data) + { + $this->writePCM($data); + } + + /** + * {@inheritDoc} + */ + public function end($data = null) + { + if ($this->isClosed) { + return; + } + + $this->write($data); + $this->close(); + } + + /** + * {@inheritDoc} + */ + public function close() + { + if ($this->isClosed) { + return; + } + + $this->pause(); + $this->emit('end', []); + $this->emit('close', []); + $this->isClosed = true; + } + + /** + * {@inheritDoc} + */ + public function pause() + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + return; + } + + $this->isPaused = true; + } + + /** + * {@inheritDoc} + */ + public function resume() + { + if ($this->isClosed) { + return; + } + + if (! $this->isPaused) { + return; + } + + $this->isPaused = false; + + foreach ($this->pcmPauseBuffer as $data) { + $this->writePCM($data); + } + + foreach ($this->opusPauseBuffer as $data) { + $this->writeOpus($data); + } + } + + /** + * {@inheritDoc} + */ + public function pipe(WritableStreamInterface $dest, array $options = []) + { + $this->pipePCM($dest, $options); + } + + /** + * Pipes PCM to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipePCM(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('pcm', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } + + /** + * Pipes Opus to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipeOpus(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('opus', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } +} diff --git a/src/Discord/Voice/RecieveStream.php b/src/Discord/Voice/RecieveStream.php index 6409105ad..f511f7f19 100644 --- a/src/Discord/Voice/RecieveStream.php +++ b/src/Discord/Voice/RecieveStream.php @@ -13,272 +13,12 @@ namespace Discord\Voice; -use Evenement\EventEmitter; -use React\Stream\DuplexStreamInterface; -use React\Stream\WritableStreamInterface; - /** * Handles recieving audio from Discord. * + * @deprecated The class was renamed, kept for backwards compatibility. * @since 3.2.0 */ -class RecieveStream extends EventEmitter implements DuplexStreamInterface +class RecieveStream extends ReceiveStream { - /** - * Contains PCM data. - * - * @var string PCM data. - */ - protected $pcmData = ''; - - /** - * Contains Opus data. - * - * @var string Opus data. - */ - protected $opusData = ''; - - /** - * Is the stream paused? - * - * @var bool Whether the stream is paused. - */ - protected $isPaused; - - /** - * Whether the stream is closed. - * - * @var bool Whether the stream is closed. - */ - protected $isClosed = false; - - /** - * The PCM pause buffer. - * - * @var array The PCM pause buffer. - */ - protected $pcmPauseBuffer = []; - - /** - * The pause buffer. - * - * @var array The pause buffer. - */ - protected $opusPauseBuffer = []; - - /** - * Constructs a stream. - */ - public function __construct() - { - // empty for now - } - - /** - * Writes PCM audio data. - * - * @param string $pcm PCM audio data. - */ - public function writePCM(string $pcm): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->pcmPauseBuffer[] = $pcm; - - return; - } - - $this->pcmData .= $pcm; - - $this->emit('pcm', [$pcm]); - } - - /** - * Writes Opus audio data. - * - * @param string $opus Opus audio data. - */ - public function writeOpus(string $opus): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->opusPauseBuffer[] = $opus; - - return; - } - - $this->opusData .= $opus; - - $this->emit('opus', [$opus]); - } - - /** - * {@inheritDoc} - */ - public function isReadable() - { - return $this->isPaused; - } - - /** - * {@inheritDoc} - */ - public function isWritable() - { - return $this->isPaused; - } - - /** - * {@inheritDoc} - */ - public function write($data) - { - $this->writePCM($data); - } - - /** - * {@inheritDoc} - */ - public function end($data = null) - { - if ($this->isClosed) { - return; - } - - $this->write($data); - $this->close(); - } - - /** - * {@inheritDoc} - */ - public function close() - { - if ($this->isClosed) { - return; - } - - $this->pause(); - $this->emit('end', []); - $this->emit('close', []); - $this->isClosed = true; - } - - /** - * {@inheritDoc} - */ - public function pause() - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - return; - } - - $this->isPaused = true; - } - - /** - * {@inheritDoc} - */ - public function resume() - { - if ($this->isClosed) { - return; - } - - if (! $this->isPaused) { - return; - } - - $this->isPaused = false; - - foreach ($this->pcmPauseBuffer as $data) { - $this->writePCM($data); - } - - foreach ($this->opusPauseBuffer as $data) { - $this->writeOpus($data); - } - } - - /** - * {@inheritDoc} - */ - public function pipe(WritableStreamInterface $dest, array $options = []) - { - $this->pipePCM($dest, $options); - } - - /** - * Pipes PCM to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipePCM(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('pcm', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } - - /** - * Pipes Opus to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipeOpus(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('opus', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } } From 538f9d450ef0cde69900976a826318cd8329d46b Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 16:54:40 +0000 Subject: [PATCH 021/121] Updates the current version of the voice client to v8 Updates the current decrypting to the latest preferred "aead_aes256_gcm_rtpsize" Adds some missing OP Codes Adds some extra functions to Buffer, that were missing & required for some packets --- .../Voice/AudioAlreadyPlayingException.php | 25 ++ .../Voice/ClientNotReadyException.php | 25 ++ src/Discord/Voice/Buffer.php | 62 ++- src/Discord/Voice/VoiceClient.php | 383 ++++++++++-------- src/Discord/Voice/VoicePacket.php | 286 +++++++++++-- src/Discord/WebSockets/Op.php | 8 +- 6 files changed, 558 insertions(+), 231 deletions(-) create mode 100644 src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php create mode 100644 src/Discord/Exceptions/Voice/ClientNotReadyException.php diff --git a/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php b/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php new file mode 100644 index 000000000..5eabe4354 --- /dev/null +++ b/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the Voice Client is already playing audio. + * + * @since 10.0.0 + */ +class AudioAlreadyPlayingException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'Audio is already playing.'); + } +} diff --git a/src/Discord/Exceptions/Voice/ClientNotReadyException.php b/src/Discord/Exceptions/Voice/ClientNotReadyException.php new file mode 100644 index 000000000..4b1451b31 --- /dev/null +++ b/src/Discord/Exceptions/Voice/ClientNotReadyException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the Voice Client is not ready. + * + * @since 10.0.0 + */ +class ClientNotReadyException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'Voice Client is not ready.'); + } +} diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 226156fb0..3b5d424a8 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -15,6 +15,7 @@ use ArrayAccess; use TrafficCophp\ByteBuffer\Buffer as BaseBuffer; +use TrafficCophp\ByteBuffer\FormatPackEnum; /** * A Byte Buffer similar to Buffer in NodeJS. @@ -29,9 +30,9 @@ class Buffer extends BaseBuffer implements ArrayAccess * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeUInt32BE(int $value, int $offset): void + public function writeUInt32BE(int $value, int $offset): self { - $this->insert('I', $value, $offset, 3); + return $this->insert(FormatPackEnum::I, $value, $offset, 3); } /** @@ -40,9 +41,9 @@ public function writeUInt32BE(int $value, int $offset): void * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeUInt64LE(int $value, int $offset): void + public function writeUInt64LE(int $value, int $offset): self { - $this->insert('P', $value, $offset, 8); + return $this->insert(FormatPackEnum::P, $value, $offset, 8); } /** @@ -51,9 +52,20 @@ public function writeUInt64LE(int $value, int $offset): void * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeInt(int $value, int $offset): void + public function writeInt(int $value, int $offset): self { - $this->insert('N', $value, $offset, 4); + return $this->insert(FormatPackEnum::N, $value, $offset, 4); + } + + /** + * Writes a unsigned integer. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeUInt(int $value, int $offset): self + { + return $this->insert(FormatPackEnum::I, $value, $offset, 4); } /** @@ -65,7 +77,19 @@ public function writeInt(int $value, int $offset): void */ public function readInt(int $offset): int { - return $this->extract('N', $offset, 4); + return $this->extract(FormatPackEnum::N, $offset, 4); + } + + /** + * Reads a signed integer. + * + * @param int $offset The offset to read from. + * + * @return int The data read. + */ + public function readUInt(int $offset): int + { + return $this->extract(FormatPackEnum::I, $offset, 4); } /** @@ -74,9 +98,9 @@ public function readInt(int $offset): int * @param int $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeShort(int $value, int $offset): void + public function writeShort(int $value, int $offset): self { - $this->insert('n', $value, $offset, 2); + return $this->insert(FormatPackEnum::n, $value, $offset, 2); } /** @@ -88,7 +112,7 @@ public function writeShort(int $value, int $offset): void */ public function readShort(int $offset): int { - return $this->extract('n', $offset, 4); + return $this->extract(FormatPackEnum::n, $offset, 4); } /** @@ -100,7 +124,17 @@ public function readShort(int $offset): int */ public function readUIntLE(int $offset): int { - return $this->extract('I', $offset, 3); + return $this->extract(FormatPackEnum::I, $offset, 3); + } + + public function readChar(int $offset): string + { + return $this->extract(FormatPackEnum::c, $offset, 1); + } + + public function readUChar(int $offset): string + { + return $this->extract(FormatPackEnum::C, $offset, 1); } /** @@ -109,9 +143,9 @@ public function readUIntLE(int $offset): int * @param string $value The value that will be written. * @param int $offset The offset that the value will be written. */ - public function writeChar(string $value, int $offset): void + public function writeChar(string $value, int $offset): self { - $this->insert('c', $value, $offset, $this->lengthMap->getLengthFor('c')); + return $this->insert(FormatPackEnum::c, $value, $offset, FormatPackEnum::c->getLength()); } /** @@ -148,7 +182,7 @@ public function writeRawString(string $value, int $offset): void #[\ReturnTypeWillChange] public function offsetGet($key) { - return $this->buffer[$key]; + return $this->buffer[$key] ?? null; } /** diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 4d46f40b7..778ff5ddc 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -26,6 +26,7 @@ use Discord\Helpers\Collection; use Discord\WebSockets\Payload; use React\ChildProcess\Process; +use Discord\Voice\ReceiveStream; use Discord\Parts\Channel\Channel; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; @@ -87,20 +88,6 @@ class VoiceClient extends EventEmitter */ protected ?string $ffmpeg; - /** - * The ReactPHP event loop. - * - * @var LoopInterface The ReactPHP event loop that will run everything. - */ - protected $loop; - - /** - * The main WebSocket instance. - * - * @var WebSocket The main WebSocket client. - */ - protected ?WebSocket $mainWebsocket; - /** * The voice WebSocket instance. * @@ -115,20 +102,6 @@ class VoiceClient extends EventEmitter */ public ?Socket $client; - /** - * The Channel that we are connecting to. - * - * @var Channel The channel that we are going to connect to. - */ - protected ?Channel $channel; - - /** - * Data from the main WebSocket. - * - * @var array Information required for the voice WebSocket. - */ - protected ?array $data; - /** * The Voice WebSocket endpoint. * @@ -198,7 +171,7 @@ class VoiceClient extends EventEmitter * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes * @var string The voice mode. */ - protected string $mode = 'aead_xchacha20_poly1305_rtpsize'; + protected string $mode = 'aead_aes256_gcm_rtpsize'; /** * The secret key used for encrypting voice. @@ -221,20 +194,6 @@ class VoiceClient extends EventEmitter */ protected bool $speaking = false; - /** - * Whether we are set as mute. - * - * @var bool Whether we are set as mute. - */ - protected bool $mute = false; - - /** - * Whether we are set as deaf. - * - * @var bool Whether we are set as deaf. - */ - protected bool $deaf = false; - /** * Whether the voice client is currently paused. * @@ -287,10 +246,19 @@ class VoiceClient extends EventEmitter /** * Voice audio recieve streams. * - * @var array Voice audio recieve streams. + * @deprecated 10.5.0 Use receiveStreams instead. + * + * @var array Voice audio recieve streams. */ protected ?array $recieveStreams; + /** + * Voice audio recieve streams. + * + * @var array Voice audio recieve streams. + */ + protected ?array $receiveStreams; + /** * The volume the audio will be encoded with. * @@ -328,16 +296,11 @@ class VoiceClient extends EventEmitter */ protected bool $userClose = false; - /** - * The logger. - * - * @var LoggerInterface|null Logger. - */ - protected $logger; - /** * The Discord voice gateway version. * + * @see https://discord.com/developers/docs/topics/voice-connections#voice-gateway-versioning-gateway-versions + * * @var int Voice version. */ protected int $version = 8; @@ -377,6 +340,8 @@ class VoiceClient extends EventEmitter */ public array $clientsConnected = []; + public array $tempFiles; + /** * Constructs the Voice Client instance. * @@ -386,15 +351,29 @@ class VoiceClient extends EventEmitter * @param LoggerInterface $logger The logger. * @param array $data More information related to the voice client. */ - public function __construct(WebSocket $websocket, LoopInterface $loop, Channel $channel, LoggerInterface $logger, array $data) - { - $this->loop = $loop; - $this->mainWebsocket = $websocket; - $this->channel = $channel; - $this->logger = $logger; - $this->data = $data; - $this->deaf = $data['deaf']; - $this->mute = $data['mute']; + + /** + * Constructs the Voice client instance + * + * @param \Ratchet\Client\WebSocket $mainWebsocket + * @param \React\EventLoop\LoopInterface $loop + * @param \Discord\Parts\Channel\Channel $channel + * @param \Psr\Log\LoggerInterface $logger + * @param array $data + * @param bool $deaf Default: false + * @param bool $mute Default: false + */ + public function __construct( + protected WebSocket $mainWebsocket, + protected LoopInterface $loop, + protected Channel $channel, + protected LoggerInterface $logger, + protected array $data, + protected bool $deaf = false, + protected bool $mute = false, + ) { + $this->deaf = $this->data['deaf'] ?? false; + $this->mute = $this->data['mute'] ?? false; $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); $this->speakingStatus = new Collection([], 'ssrc'); $this->dnsConfig = $data['dnsConfig']; @@ -403,9 +382,9 @@ public function __construct(WebSocket $websocket, LoopInterface $loop, Channel $ /** * Starts the voice client. * - * @return void|bool + * @return bool */ - public function start() + public function start(): bool { if ( ! $this->checkForFFmpeg() || @@ -415,6 +394,7 @@ public function start() } $this->initSockets(); + return true; } /** @@ -447,17 +427,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $ip = $port = ''; $ws->on('message', function (Message $message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port): void { - if ($message?->getPayload() === null || json_decode($message->getPayload() ?? '') === null) { - - dd($message); - return; - } - - $data = json_decode($message->getPayload()); - - echo 'Received: ' . json_encode($data) . PHP_EOL; - $this->emit('ws-message', [$message, $this]); switch ($data->op) { @@ -486,11 +456,15 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->emit('resumed', [$this]); } + if (! $this->deaf && $this->secretKey) { + $this->client->on('message', fn (string $message, string $address, Socket $client) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->logger))); + } + break; case Op::VOICE_SPEAKING: // currently connected users $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); - $this->speakingStatus[$data->d->ssrc] = $data->d; + $this->speakingStatus[$data->d->user_id] = $data->d; break; case Op::VOICE_HELLO: $this->heartbeatInterval = $data->d->heartbeat_interval; @@ -507,9 +481,12 @@ public function handleWebSocketConnection(WebSocket $ws): void $sendHeartbeat(); $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, $sendHeartbeat); break; - case Op::VOICE_CLIENT_CONNECT: + case Op::VOICE_CLIENTS_CONNECTED: # "d" contains an array with ['user_ids' => array] - $this->clientsConnected[] = $data->d->user_ids; + $this->clientsConnected = $data->d->user_ids; + break; + case Op::VOICE_CLIENT_DISCONNECT: + unset($this->clientsConnected[$data->d->user_id]); break; case Op::VOICE_CLIENT_UNKNOWN_15: case Op::VOICE_CLIENT_UNKNOWN_18: @@ -568,7 +545,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('connected to voice UDP'); $this->client = $client; - $this->loop->addTimer(1, function () use ($buffer) { + $this->loop->addTimer(0.1, function () use ($buffer) { $this->client->send((string) $buffer); }); @@ -626,15 +603,9 @@ public function handleWebSocketConnection(WebSocket $ws): void ], ], ]); - - $client->removeListener('message', $decodeUDP); - - if (! $this->deaf) { - $client->on('message', [$this, 'handleAudioData']); - } }; - $client->on('message', $decodeUDP); + $client->once('message', $decodeUDP); }, function (Throwable $e): void { $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); @@ -643,8 +614,7 @@ public function handleWebSocketConnection(WebSocket $ws): void break; } default: - echo 'Unknown opcode: ' . $data->op . PHP_EOL; - echo 'Data: ' . json_encode($data) . PHP_EOL; + $this->logger->warning('Unknown opcode.', $data); break; } }); @@ -1106,6 +1076,7 @@ private function reset(): void * Sends a buffer to the UDP socket. * * @param string $data The data to send to the UDP server. + * @todo Fix after new change in VoicePacket */ private function sendBuffer(string $data): void { @@ -1475,17 +1446,31 @@ public function handleVoiceStateUpdate(object $data): void * * @param int|string $id Either a SSRC or User ID. * + * @deprecated 10.5.0 Use getReceiveStream instead. + * * @return null|RecieveStream|ReceiveStream */ public function getRecieveStream($id): null|RecieveStream|ReceiveStream { - if (isset($this->recieveStreams[$id])) { - return $this->recieveStreams[$id]; + return $this->getReceiveStream($id); + } + + /** + * Gets a receive voice stream. + * + * @param int|string $id Either a SSRC or User ID. + * + * @return null|ReceiveStream + */ + public function getReceiveStream($id): null|ReceiveStream + { + if (isset($this->receiveStreams[$id])) { + return $this->receiveStreams[$id]; } foreach ($this->speakingStatus as $status) { if ($status->user_id == $id) { - return $this->recieveStreams[$status->ssrc]; + return $this->receiveStreams[$status->ssrc]; } } @@ -1497,140 +1482,138 @@ public function getRecieveStream($id): null|RecieveStream|ReceiveStream * * @param string $message The data from the UDP server. */ - protected function handleAudioData(string $message): void + protected function handleAudioData(VoicePacket $voicePacket): void { - # echo 'Handling audio data' . PHP_EOL; - # echo 'Message: ' . $message . PHP_EOL; - - + $message = $voicePacket?->decryptedAudio ?? null; - ##### NON AI CODE DOWN HERE ######### - - $voicePacket = new VoicePacket(); - // $voicePacket->unpack($message); - - - // Supondo que $message contenha a mensagem completa e $this->secretKey esteja definida. - $len = strlen($message); - - // 1. Calcular o tamanho do header (12 bytes ou 16 se o bit de extensão estiver setado) - $headerSize = 12; - $firstByte = ord($message[0]); - if (($firstByte >> 4) & 0x01) { - $headerSize += 4; - } - - // 2. Extrair o header - $header = substr($message, 0, $headerSize); - - // 3. Preparar o nonce: pegar os últimos 4 bytes e preencher à esquerda com zeros - $nonce = substr($message, $len - 4, 4); - $nonceBuffer = str_pad($nonce, SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES, "\0", STR_PAD_RIGHT); - - // 4. Extrair o ciphertext e a tag de autenticação - // A mensagem: [header][ciphertext][auth tag][nonce] - // O tamanho do ciphertext é: total - headerSize - 16 (auth tag) - 4 (nonce) - $encryptedLength = $len - $headerSize - 16 - 4; - $cipherText = substr($message, $headerSize, $encryptedLength); - $authTag = substr($message, $headerSize + $encryptedLength, 16); - - // Concatenar ciphertext e auth tag, como na versão JS - $combined = $cipherText . $authTag; - - try { - // 5. Decriptar a mensagem usando o sodium - $resultMessage = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt( - $combined, - $header, - $nonceBuffer, - $this->secretKey - ); - - if ($resultMessage === false) { - // Se a decriptação falhar, log o erro e retorne - $this->logger->error('Failed to decode voice packet.', [ - 'padded_nonce' => $nonceBuffer, - 'message_len' => $len, - ]); + if (! $message) { + if (! $this->speakingStatus->get('ssrc', $voicePacket->getSSRC())) { + // We don't have a speaking status for this SSRC + // Probably a "ping" to the udp socket return; } - - // 6. Verificar e remover a extensão, se presente - if (substr($message, 12, 2) === "\xBE\xDE") { - // Lê os 2 bytes após o identificador da extensão para obter o tamanho da extensão - $extLengthData = substr($message, 14, 2); - $headerExtensionLength = unpack('n', $extLengthData)[1]; - // Remove 4 * headerExtensionLength bytes do início do resultado decriptado - $resultMessage = substr($resultMessage, 4 * $headerExtensionLength); - } - } catch (\Exception $e) { - $this->logger->error('Exception occurred when decoding voice packet: ' . $e->getMessage()); + // There's no message or the message threw an error inside the decrypt function + $this->logger->warning('No audio data.', ['voicePacket' => $voicePacket]); return; } - dd('Success!', $resultMessage); - - $message = $resultMessage; $this->emit('raw', [$message, $this]); - $vp = VoicePacket::make($voicePacket->getHeader().$message); - $ss = $this->speakingStatus->get('ssrc', $vp->getSSRC()); - $decoder = $this->voiceDecoders[$vp->getSSRC()] ?? null; + $ss = $this->speakingStatus->get('ssrc', $voicePacket->getSSRC()); + $decoder = $this->voiceDecoders[$voicePacket->getSSRC()] ?? null; if (null === $ss) { // for some reason we don't have a speaking status - $this->logger->warning('Received voice packet from unknown SSRC.', ['ssrc' => $vp->getSSRC()]); + $this->logger->warning('Unknown SSRC.', ['ssrc' => $voicePacket->getSSRC(), 't' => $voicePacket->getTimestamp()]); return; } if (null === $decoder) { + echo 'Creating decoder for ' . $voicePacket->getSSRC() . PHP_EOL; // make a decoder - if (! isset($this->recieveStreams[$ss->ssrc])) { - $this->recieveStreams[$ss->ssrc] = new RecieveStream(); + if (! isset($this->receiveStreams[$ss->ssrc])) { + $this->receiveStreams[$ss->ssrc] = new ReceiveStream(); - $this->recieveStreams[$ss->ssrc]->on('pcm', function ($d) { + $this->receiveStreams[$ss->ssrc]->on('pcm', function ($d) { echo 'PCM: ' . $d . PHP_EOL; $this->emit('channel-pcm', [$d, $this]); }); - $this->recieveStreams[$ss->ssrc]->on('opus', function ($d) { + $this->receiveStreams[$ss->ssrc]->on('opus', function ($d) { echo 'OPUS: ' . $d . PHP_EOL; $this->emit('channel-opus', [$d, $this]); }); } $createDecoder = function () use (&$createDecoder, $ss) { - $decoder = $this->dcaDecode(); + $decoder = $this->ffmpegDecode(); $decoder->start($this->loop); - $decoder->stdout->on('data', function ($data) use ($ss) { - echo 'Data: ' . $data . PHP_EOL; - $this->recieveStreams[$ss->ssrc]->writePCM($data); - }); - $decoder->stderr->on('data', function ($data) use ($ss) { - $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); - $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); + // Handle stdout + $stdoutHandle = fopen($this->tempFiles['stdout'], 'r'); + $this->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { + $data = fread($stdoutHandle, 8192); + if ($data) { + $this->receiveStreams[$ss->ssrc]->writePCM($data); + } }); - $decoder->on('exit', function ($code, $term) use ($ss, &$createDecoder) { - if ($code > 0) { - $this->emit('decoder-error', [$code, $term, $ss]); - $createDecoder(); + // Handle stderr + $stderrHandle = fopen($this->tempFiles['stderr'], 'r'); + $this->loop->addPeriodicTimer(0.1, function () use ($stderrHandle, $ss) { + $data = fread($stderrHandle, 8192); + if ($data) { + $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); + $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); } }); + // Store the decoder $this->voiceDecoders[$ss->ssrc] = $decoder; + + // Monitor the process for exit + $this->monitorProcessExit($decoder, $ss, $createDecoder); }; $createDecoder(); - $decoder = $this->voiceDecoders[$vp->getSSRC()] ?? null; + $decoder = $this->voiceDecoders[$voicePacket->getSSRC()] ?? null; } - $buff = new Buffer(strlen($vp->getData()) + 2); - $buff->write(pack('s', strlen($vp->getData())), 0); - $buff->write($vp->getData(), 2); + $audioData = $voicePacket->getAudioData(); - $decoder->stdin->write((string) $buff); + $buff = new Buffer(strlen($audioData) + 2); + $buff->write(pack('s', strlen($audioData)), 0); + $buff->write($audioData, 2); + + $stdinHandle = fopen($this->tempFiles['stdin'], 'a'); // Use append mode + fwrite($stdinHandle, (string) $buff); + fflush($stdinHandle); // Make sure the data is written immediately + fclose($stdinHandle); + } + + /** + * Monitor a process for exit and trigger callbacks when it exits + * + * @param Process $process The process to monitor + * @param object $ss The speaking status object + * @param callable $createDecoder Function to create a new decoder if needed + */ + private function monitorProcessExit(Process $process, $ss, callable $createDecoder): void + { + // Store the process ID + $pid = $process->getPid(); + + // Check every second if the process is still running + $timer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss, &$createDecoder, &$timer, $pid) { + // Check if the process is still running + if (!$process->isRunning()) { + // Get the exit code + $exitCode = $process->getExitCode(); + + // Clean up the timer + $this->loop->cancelTimer($timer); + + // If exit code indicates an error, emit event and recreate decoder + if ($exitCode > 0) { + $this->emit('decoder-error', [$exitCode, null, $ss]); + $createDecoder(); + } + + // Clean up temporary files + $this->cleanupTempFiles(); + } + }); + } + + private function cleanupTempFiles(): void + { + if (isset($this->tempFiles)) { + foreach ($this->tempFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + } } private function handleDavePrepareTransition($data) @@ -1887,6 +1870,45 @@ public function dcaDecode(int $channels = 2, ?int $frameSize = null): Process return new Process("{$this->dca} {$flags}"); } + public function ffmpegDecode(int $channels = 2, ?int $frameSize = null): Process + { + if (null === $frameSize) { + $frameSize = round($this->frameSize * 48); + } + + $flags = [ + '-ac:opus', $channels, // Channels + '-ab', round($this->bitrate / 1000), // Bitrate + '-as', $frameSize, // Frame Size + '-ar', '48000', // Audio Rate + '-mode', 'decode', // Decode mode + ]; + + $flags = implode(' ', $flags); + + // Create temporary files for stdin, stdout, and stderr + $tempDir = sys_get_temp_dir(); + $stdinFile = tempnam($tempDir, 'discord_ffmpeg_stdin_' . $this->ssrc); + $stdoutFile = tempnam($tempDir, 'discord_ffmpeg_stdout_' . $this->ssrc); + $stderrFile = tempnam($tempDir, 'discord_ffmpeg_stderr_' . $this->ssrc); + + // Store temp file paths for later cleanup + $this->tempFiles = [ + 'stdin' => $stdinFile, + 'stdout' => $stdoutFile, + 'stderr' => $stderrFile, + ]; + + return new Process( + "{$this->ffmpeg} {$flags}", + fds: [ + ['file', $stdinFile, 'w'], + ['file', $stdoutFile, 'w+'], + ['file', $stderrFile, 'w+'], + ] + ); + } + /** * Returns the connected channel. * @@ -1908,4 +1930,5 @@ public function insertSilence(): void $this->sendBuffer(self::SILENCE_FRAME); } } + } diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index 9f7df8045..bfc3f19d5 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -13,6 +13,9 @@ namespace Discord\Voice; +use Monolog\Logger; +use TrafficCophp\ByteBuffer\FormatPackEnum; + /** * A voice packet received from Discord. * @@ -24,20 +27,41 @@ */ class VoicePacket { + + # RTP Header Constants public const RTP_HEADER_BYTE_LENGTH = 12; public const RTP_VERSION_PAD_EXTEND_INDEX = 0; + public const RTP_VERSION_PAD_EXTEND = 0x80; public const RTP_PAYLOAD_INDEX = 1; + public const RTP_PAYLOAD_TYPE = 0x78; public const SEQ_INDEX = 2; + public const TIMESTAMP_INDEX = 4; + public const SSRC_INDEX = 8; + public const NONCE_LENGTH = 12; + + public const NONCE_BYTE_LENGTH = 4; + + public const AUTH_TAG_LENGTH = 16; + + /** + * The audio header, in binary, containing the version, flags, sequence, timestamp, and SSRC. + * + * @var string + */ + protected string $header; + /** - * The voice packet buffer. + * The buffer containing the voice packet. + * + * @deprecated * * @var Buffer */ @@ -48,21 +72,70 @@ class VoicePacket * * @var int The client SSRC. */ - protected $ssrc; + public ?int $ssrc; /** * The packet sequence. * * @var int The packet sequence. */ - protected $seq; + public ?int $seq; /** * The packet timestamp. * * @var int The packet timestamp. */ - protected $timestamp; + public ?int $timestamp; + + /** + * The version and flags. + * + * @var string The version and flags. + */ + public ?string $versionPlusFlags; + + /** + * The payload type. + * + * @var string The payload type. + */ + public ?string $payloadType; + + /** + * The encrypted audio. + * + * @var string The encrypted audio. + */ + public ?string $encryptedAudio; + + /** + * The dencrypted audio. + * + * @var string + */ + public null|string|false $decryptedAudio; + + /** + * The secret key. + * + * @var string The secret key. + */ + public ?string $secretKey; + + /** + * The raw data + * + * @var string + */ + private string $rawData; + + /** + * Current packet header size. May differ depending on the RTP header. + * + * @var int + */ + private int $headerSize; /** * Constructs the voice packet. @@ -74,22 +147,142 @@ class VoicePacket * @param bool $encryption Whether the packet should be encrypted. * @param string|null $key The encryption key. */ - public function __construct(string $data, int $ssrc, int $seq, int $timestamp, bool $encryption = false, ?string $key = null) + public function __construct(?string $data = null, ?int $ssrc = null, ?int $seq = null, ?int $timestamp = null, bool $encryption = false, private ?string $key = null, private ?Logger $log = null) + { + $this->unpack($data) + ->decrypt(); + } + + /** + * Unpacks the voice message into an array. + * + * C1 (unsigned char) | Version + Flags | 1 bytes | Single byte value of 0x80 + * C1 (unsigned char) | Payload Type | 1 bytes | Single byte value of 0x78 + * n (Unsigned short (big endian)) | Sequence | 2 bytes + * I (Unsigned integer (big endian)) | Timestamp | 4 bytes + * I (Unsigned integer (big endian)) | SSRC | 4 bytes + * a* (string) | Encrypted audio | n bytes | Binary data of the encrypted audio. + * + * @see https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes-voice-packet-structure + * @see https://www.php.net/manual/en/function.unpack.php + * @see https://www.php.net/manual/en/function.pack.php For the formats + */ + public function unpack(string $message): self { - $this->ssrc = $ssrc; - $this->seq = $seq; - $this->timestamp = $timestamp; + $byteHeader = $this->setHeader($message); - if (! $encryption) { - $this->initBufferNoEncryption($data); - } else { - $this->initBufferEncryption($data, $key); + if (! $byteHeader) { + $this->log->warning('Failed to unpack voice packet Header.', ['message' => $message]); + echo 'Failed to unpack voice packet Header.' . PHP_EOL; + return $this; + } + + $byteData = substr( + $message, + self::RTP_HEADER_BYTE_LENGTH, + strlen($message) - self::AUTH_TAG_LENGTH - self::NONCE_LENGTH + ); + + $unpackedMessage = unpack('Cfirst/Csecond/nseq/Ntimestamp/Nssrc', $byteHeader); + + if (! $unpackedMessage) { + $this->log->warning('Failed to unpack voice packet.', ['message' => $message]); + return $this; + } + + $this->rawData = $message; + $this->header = $byteHeader; + $this->encryptedAudio = $byteData; + + $this->ssrc = $unpackedMessage['ssrc']; + $this->seq = $unpackedMessage['seq']; + $this->timestamp = $unpackedMessage['timestamp']; + $this->payloadType = $unpackedMessage['payload_type'] ?? null; + $this->versionPlusFlags = $unpackedMessage['version_and_flags'] ?? null; + + return $this; + } + + /** + * Decrypts the voice message. + * + * @param string|null $message The message to decrypt. + * + * @return false|null|string + */ + public function decrypt(?string $message = null): false|null|string + { + if (! $message) { + $message = $this?->rawData ?? null; + } + + if (empty($message)) { + // throw error here + return null; + } + + $len = strlen($message); + + // 2. Extract the header + $header = $this->getHeader(); + if (! $header) { + $this->log->warning('Invalid Voice Header.', ['message' => $message]); + return false; + } + + // 3. Extract the nonce + $nonce = substr($message, $len - self::NONCE_BYTE_LENGTH, self::NONCE_BYTE_LENGTH); + // 4. Pad the nonce to 12 bytes + $nonceBuffer = str_pad($nonce, SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, "\0", STR_PAD_RIGHT); + + // 5. Extract the ciphertext and auth tag + // The message: [header][ciphertext][auth tag][nonce] + // The size of the ciphertext is: total - headerSize - 16 (auth tag) - 4 (nonce) + $encryptedLength = $len - $this->headerSize - self::AUTH_TAG_LENGTH - self::NONCE_BYTE_LENGTH; + $cipherText = substr($message, $this->headerSize, $encryptedLength); + $authTag = substr($message, $this->headerSize + $encryptedLength, self::AUTH_TAG_LENGTH); + + // Concatenate the ciphertext and the auth tag + $combined = "$cipherText$authTag"; + + $resultMessage = null; + + try { + // Decrypt the message + $resultMessage = sodium_crypto_aead_aes256gcm_decrypt( + $combined, + $header, + $nonceBuffer, + $this->key + ); + + // If decryption fails, log the error and return + // Most of the time, the length is 20 bytes either for a ping, or an empty voice/udp packet + if ($resultMessage === false && strlen($cipherText) !== 20) { + $this->log->warning('Failed to decode voice packet.', ['ssrc' => $this->ssrc]); + } + // Check if the message contains an extension and remove it + elseif (substr($message, 12, 2) === "\xBE\xDE") { + // Reads the 2 bytes after the extension identifier to get the extension length + $extLengthData = substr($message, 14, 2); + $headerExtensionLength = unpack('n', $extLengthData)[1]; + + // Remove 4 * headerExtensionLength bytes from the beginning of the decrypted result + $resultMessage = substr($resultMessage, 4 * $headerExtensionLength); + } + } catch (\Throwable $e) { + $this->log->error('Exception occurred when decoding voice packet: ' . $e->getMessage()); + $this->log->error('Trace: ' . $e->getTraceAsString()); + } finally { + return $this->decryptedAudio = $resultMessage; } } /** * Initilizes the buffer with no encryption. * + * @deprecated + * * @param string $data The Opus data to encode. */ protected function initBufferNoEncryption(string $data): void @@ -97,11 +290,9 @@ protected function initBufferNoEncryption(string $data): void $data = (string) $data; $header = $this->buildHeader(); - $buffer = new Buffer(strlen((string) $header) + strlen($data)); - $buffer->write((string) $header, 0); - $buffer->write($data, 12); - - $this->buffer = $buffer; + $this->buffer = Buffer::make(strlen((string) $header) + strlen($data)) + ->write((string) $header, 0) + ->write($data, 12); } /** @@ -132,13 +323,36 @@ protected function initBufferEncryption(string $data, string $key): void protected function buildHeader(): Buffer { $header = new Buffer(self::RTP_HEADER_BYTE_LENGTH); - $header[self::RTP_VERSION_PAD_EXTEND_INDEX] = pack('c', self::RTP_VERSION_PAD_EXTEND); - $header[self::RTP_PAYLOAD_INDEX] = pack('c', self::RTP_PAYLOAD_TYPE); - $header->writeShort($this->seq, self::SEQ_INDEX); - $header->writeInt($this->timestamp, self::TIMESTAMP_INDEX); - $header->writeInt($this->ssrc, self::SSRC_INDEX); + $header[self::RTP_VERSION_PAD_EXTEND_INDEX] = pack(FormatPackEnum::C->value, self::RTP_VERSION_PAD_EXTEND); + $header[self::RTP_PAYLOAD_INDEX] = pack(FormatPackEnum::C->value, self::RTP_PAYLOAD_TYPE); + return $header->writeShort($this->seq, self::SEQ_INDEX) + ->writeUInt($this->timestamp, self::TIMESTAMP_INDEX) + ->writeUInt($this->ssrc, self::SSRC_INDEX); + } + + public function setHeader(?string $message = null): ?string + { + if (null === $message) { + $message = $this?->rawData; + } + + if (empty($message)) { + // throw error here + return null; + } + + $this->headerSize = self::RTP_HEADER_BYTE_LENGTH; + $firstByte = ord($message[0]); + if (($firstByte >> 4) & 0x01) { + $this->headerSize += 4; + } - return $header; + return substr($message, 0, $this->headerSize); + } + + public function getHeader(): ?string + { + return $this?->header ?? null; } /** @@ -171,16 +385,6 @@ public function getSSRC(): int return $this->ssrc; } - /** - * Returns the header. - * - * @return string The packet header. - */ - public function getHeader(): string - { - return $this->buffer->read(0, self::RTP_HEADER_BYTE_LENGTH); - } - /** * Returns the data. * @@ -203,6 +407,7 @@ public static function make(string $data): VoicePacket $n = new self('', 0, 0, 0); $buff = new Buffer($data); $n->setBuffer($buff); + unset($buff); return $n; } @@ -219,8 +424,8 @@ public function setBuffer(Buffer $buffer): self $this->buffer = $buffer; $this->seq = $this->buffer->readShort(self::SEQ_INDEX); - $this->timestamp = $this->buffer->readInt(self::TIMESTAMP_INDEX); - $this->ssrc = $this->buffer->readInt(self::SSRC_INDEX); + $this->timestamp = $this->buffer->readUInt(self::TIMESTAMP_INDEX); + $this->ssrc = $this->buffer->readUInt(self::SSRC_INDEX); return $this; } @@ -234,4 +439,15 @@ public function __toString(): string { return (string) $this->buffer; } + + /** + * Retrieves the decrypted audio data. + * Will return null if the audio data is not decrypted and false on error. + * + * @return null|string|false + */ + public function getAudioData(): null|string|false + { + return $this?->decryptedAudio; + } } diff --git a/src/Discord/WebSockets/Op.php b/src/Discord/WebSockets/Op.php index d9d1e12e2..132afb13e 100644 --- a/src/Discord/WebSockets/Op.php +++ b/src/Discord/WebSockets/Op.php @@ -59,7 +59,6 @@ class Op /** Request soundboard sounds. */ public const REQUEST_SOUNDBOARD_SOUNDS = 31; - /** * Voice Opcodes. * @@ -89,9 +88,14 @@ class Op /** Acknowledge a successful session resume. */ public const VOICE_RESUMED = 9; /** One or more clients have connected to the voice channel */ - public const VOICE_CLIENT_CONNECT = 11; + public const VOICE_CLIENTS_CONNECTED = 11; /** A client has disconnected from the voice channel. */ public const VOICE_CLIENT_DISCONNECT = 13; + /** Was not documented within the op codes and statuses*/ + public const VOICE_CLIENT_UNKNOWN_15 = 15; + public const VOICE_CLIENT_UNKNOWN_18 = 18; + /** NOT DOCUMENTED - Assumed to be the platform type in which the user is. */ + public const VOICE_CLIENT_PLATFORM = 20; /** A downgrade from the DAVE protocol is upcoming. */ public const VOICE_DAVE_PREPARE_TRANSITION = 21; /** Execute a previously announced protocol transition. */ From 4a372e8b7abb303b85c27ec2bea943367db3cc8a Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 12 Mar 2025 17:03:42 +0000 Subject: [PATCH 022/121] Removes debugging logs --- src/Discord/Voice/ReceiveStream.php | 3 --- src/Discord/Voice/VoiceClient.php | 3 --- src/Discord/Voice/VoicePacket.php | 1 - 3 files changed, 7 deletions(-) diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php index 84be35183..85a4473df 100644 --- a/src/Discord/Voice/ReceiveStream.php +++ b/src/Discord/Voice/ReceiveStream.php @@ -80,7 +80,6 @@ public function __construct() */ public function writePCM(string $pcm): void { - echo 'called pcm'; if ($this->isClosed) { return; } @@ -103,8 +102,6 @@ public function writePCM(string $pcm): void */ public function writeOpus(string $opus): void { - echo 'called opus'; - if ($this->isClosed) { return; } diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 778ff5ddc..6b895edf6 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -1509,18 +1509,15 @@ protected function handleAudioData(VoicePacket $voicePacket): void } if (null === $decoder) { - echo 'Creating decoder for ' . $voicePacket->getSSRC() . PHP_EOL; // make a decoder if (! isset($this->receiveStreams[$ss->ssrc])) { $this->receiveStreams[$ss->ssrc] = new ReceiveStream(); $this->receiveStreams[$ss->ssrc]->on('pcm', function ($d) { - echo 'PCM: ' . $d . PHP_EOL; $this->emit('channel-pcm', [$d, $this]); }); $this->receiveStreams[$ss->ssrc]->on('opus', function ($d) { - echo 'OPUS: ' . $d . PHP_EOL; $this->emit('channel-opus', [$d, $this]); }); } diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index bfc3f19d5..d73ac682d 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -173,7 +173,6 @@ public function unpack(string $message): self if (! $byteHeader) { $this->log->warning('Failed to unpack voice packet Header.', ['message' => $message]); - echo 'Failed to unpack voice packet Header.' . PHP_EOL; return $this; } From 74e1908b94a6baf9d2b60fb406c9977bff11a8bf Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 14 Mar 2025 12:51:01 +0000 Subject: [PATCH 023/121] Updates const name --- src/Discord/Voice/VoiceClient.php | 2 +- src/Discord/WebSockets/Op.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 6b895edf6..722c94402 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -481,7 +481,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $sendHeartbeat(); $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, $sendHeartbeat); break; - case Op::VOICE_CLIENTS_CONNECTED: + case Op::VOICE_CLIENTS_CONNECT: # "d" contains an array with ['user_ids' => array] $this->clientsConnected = $data->d->user_ids; break; diff --git a/src/Discord/WebSockets/Op.php b/src/Discord/WebSockets/Op.php index 132afb13e..3b8782057 100644 --- a/src/Discord/WebSockets/Op.php +++ b/src/Discord/WebSockets/Op.php @@ -88,7 +88,7 @@ class Op /** Acknowledge a successful session resume. */ public const VOICE_RESUMED = 9; /** One or more clients have connected to the voice channel */ - public const VOICE_CLIENTS_CONNECTED = 11; + public const VOICE_CLIENTS_CONNECT = 11; /** A client has disconnected from the voice channel. */ public const VOICE_CLIENT_DISCONNECT = 13; /** Was not documented within the op codes and statuses*/ From 3086ed3ec59a88a4ff864ed1ecf7a2985b1cd1ed Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 14 Mar 2025 14:40:31 +0000 Subject: [PATCH 024/121] Updates recieve class. --- src/Discord/Voice/ReceiveStream.php | 259 +-------------------------- src/Discord/Voice/RecieveStream.php | 263 +++++++++++++++++++++++++++- 2 files changed, 263 insertions(+), 259 deletions(-) diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php index 85a4473df..4162980bc 100644 --- a/src/Discord/Voice/ReceiveStream.php +++ b/src/Discord/Voice/ReceiveStream.php @@ -21,263 +21,6 @@ * @since 10.5.0 The class was renamed to ReceiveStream. * @since 3.2.0 */ -class ReceiveStream extends EventEmitter implements DuplexStreamInterface +class ReceiveStream extends RecieveStream { - /** - * Contains PCM data. - * - * @var string PCM data. - */ - protected $pcmData = ''; - - /** - * Contains Opus data. - * - * @var string Opus data. - */ - protected $opusData = ''; - - /** - * Is the stream paused? - * - * @var bool Whether the stream is paused. - */ - protected $isPaused; - - /** - * Whether the stream is closed. - * - * @var bool Whether the stream is closed. - */ - protected $isClosed = false; - - /** - * The PCM pause buffer. - * - * @var array The PCM pause buffer. - */ - protected $pcmPauseBuffer = []; - - /** - * The pause buffer. - * - * @var array The pause buffer. - */ - protected $opusPauseBuffer = []; - - /** - * Constructs a stream. - */ - public function __construct() - { - // empty for now - } - - /** - * Writes PCM audio data. - * - * @param string $pcm PCM audio data. - */ - public function writePCM(string $pcm): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->pcmPauseBuffer[] = $pcm; - - return; - } - - $this->pcmData .= $pcm; - - $this->emit('pcm', [$pcm]); - } - - /** - * Writes Opus audio data. - * - * @param string $opus Opus audio data. - */ - public function writeOpus(string $opus): void - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - $this->opusPauseBuffer[] = $opus; - - return; - } - - $this->opusData .= $opus; - - $this->emit('opus', [$opus]); - } - - /** - * {@inheritDoc} - */ - public function isReadable() - { - return !$this->isPaused && !$this->isClosed; - } - - /** - * {@inheritDoc} - */ - public function isWritable() - { - return $this->isReadable(); - } - - /** - * {@inheritDoc} - */ - public function write($data) - { - $this->writePCM($data); - } - - /** - * {@inheritDoc} - */ - public function end($data = null) - { - if ($this->isClosed) { - return; - } - - $this->write($data); - $this->close(); - } - - /** - * {@inheritDoc} - */ - public function close() - { - if ($this->isClosed) { - return; - } - - $this->pause(); - $this->emit('end', []); - $this->emit('close', []); - $this->isClosed = true; - } - - /** - * {@inheritDoc} - */ - public function pause() - { - if ($this->isClosed) { - return; - } - - if ($this->isPaused) { - return; - } - - $this->isPaused = true; - } - - /** - * {@inheritDoc} - */ - public function resume() - { - if ($this->isClosed) { - return; - } - - if (! $this->isPaused) { - return; - } - - $this->isPaused = false; - - foreach ($this->pcmPauseBuffer as $data) { - $this->writePCM($data); - } - - foreach ($this->opusPauseBuffer as $data) { - $this->writeOpus($data); - } - } - - /** - * {@inheritDoc} - */ - public function pipe(WritableStreamInterface $dest, array $options = []) - { - $this->pipePCM($dest, $options); - } - - /** - * Pipes PCM to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipePCM(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('pcm', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } - - /** - * Pipes Opus to a destination stream. - * - * @param WritableStreamInterface $dest The stream to pipe to. - * @param array $options An array of options. - */ - public function pipeOpus(WritableStreamInterface $dest, array $options = []): void - { - if ($this->isClosed) { - return; - } - - $this->on('opus', function ($data) use ($dest) { - $feedmore = $dest->write($data); - - if (false === $feedmore) { - $this->pause(); - } - }); - - $dest->on('drain', function () { - $this->resume(); - }); - - $end = isset($options['end']) ? $options['end'] : true; - if ($end && $this !== $dest) { - $this->on('end', function () use ($dest) { - $dest->end(); - }); - } - } } diff --git a/src/Discord/Voice/RecieveStream.php b/src/Discord/Voice/RecieveStream.php index f511f7f19..7a61bdf31 100644 --- a/src/Discord/Voice/RecieveStream.php +++ b/src/Discord/Voice/RecieveStream.php @@ -13,12 +13,273 @@ namespace Discord\Voice; +use Evenement\EventEmitter; +use React\Stream\DuplexStreamInterface; +use React\Stream\WritableStreamInterface; + /** * Handles recieving audio from Discord. * * @deprecated The class was renamed, kept for backwards compatibility. * @since 3.2.0 */ -class RecieveStream extends ReceiveStream +class RecieveStream extends EventEmitter implements DuplexStreamInterface { + /** + * Contains PCM data. + * + * @var string PCM data. + */ + protected $pcmData = ''; + + /** + * Contains Opus data. + * + * @var string Opus data. + */ + protected $opusData = ''; + + /** + * Is the stream paused? + * + * @var bool Whether the stream is paused. + */ + protected $isPaused; + + /** + * Whether the stream is closed. + * + * @var bool Whether the stream is closed. + */ + protected $isClosed = false; + + /** + * The PCM pause buffer. + * + * @var array The PCM pause buffer. + */ + protected $pcmPauseBuffer = []; + + /** + * The pause buffer. + * + * @var array The pause buffer. + */ + protected $opusPauseBuffer = []; + + /** + * Constructs a stream. + */ + public function __construct() + { + // empty for now + } + + /** + * Writes PCM audio data. + * + * @param string $pcm PCM audio data. + */ + public function writePCM(string $pcm): void + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->pcmPauseBuffer[] = $pcm; + + return; + } + + $this->pcmData .= $pcm; + + $this->emit('pcm', [$pcm]); + } + + /** + * Writes Opus audio data. + * + * @param string $opus Opus audio data. + */ + public function writeOpus(string $opus): void + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + $this->opusPauseBuffer[] = $opus; + + return; + } + + $this->opusData .= $opus; + + $this->emit('opus', [$opus]); + } + + /** + * {@inheritDoc} + */ + public function isReadable() + { + return !$this->isPaused && !$this->isClosed; + } + + /** + * {@inheritDoc} + */ + public function isWritable() + { + return $this->isReadable(); + } + + /** + * {@inheritDoc} + */ + public function write($data) + { + $this->writePCM($data); + } + + /** + * {@inheritDoc} + */ + public function end($data = null) + { + if ($this->isClosed) { + return; + } + + $this->write($data); + $this->close(); + } + + /** + * {@inheritDoc} + */ + public function close() + { + if ($this->isClosed) { + return; + } + + $this->pause(); + $this->emit('end', []); + $this->emit('close', []); + $this->isClosed = true; + } + + /** + * {@inheritDoc} + */ + public function pause() + { + if ($this->isClosed) { + return; + } + + if ($this->isPaused) { + return; + } + + $this->isPaused = true; + } + + /** + * {@inheritDoc} + */ + public function resume() + { + if ($this->isClosed) { + return; + } + + if (! $this->isPaused) { + return; + } + + $this->isPaused = false; + + foreach ($this->pcmPauseBuffer as $data) { + $this->writePCM($data); + } + + foreach ($this->opusPauseBuffer as $data) { + $this->writeOpus($data); + } + } + + /** + * {@inheritDoc} + */ + public function pipe(WritableStreamInterface $dest, array $options = []) + { + $this->pipePCM($dest, $options); + } + + /** + * Pipes PCM to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipePCM(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('pcm', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } + + /** + * Pipes Opus to a destination stream. + * + * @param WritableStreamInterface $dest The stream to pipe to. + * @param array $options An array of options. + */ + public function pipeOpus(WritableStreamInterface $dest, array $options = []): void + { + if ($this->isClosed) { + return; + } + + $this->on('opus', function ($data) use ($dest) { + $feedmore = $dest->write($data); + + if (false === $feedmore) { + $this->pause(); + } + }); + + $dest->on('drain', function () { + $this->resume(); + }); + + $end = isset($options['end']) ? $options['end'] : true; + if ($end && $this !== $dest) { + $this->on('end', function () use ($dest) { + $dest->end(); + }); + } + } } From 3fbd34bd81137c1701546f61bfbef83533e7770b Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 16 Mar 2025 17:13:01 +0000 Subject: [PATCH 025/121] Updates Discord class usage for voices into 1 class only. --- src/Discord/Discord.php | 191 ++++++++---------------------- src/Discord/Voice/Voice.php | 152 ++++++++++++++++++++++++ src/Discord/Voice/VoiceClient.php | 36 ++---- 3 files changed, 212 insertions(+), 167 deletions(-) create mode 100644 src/Discord/Voice/Voice.php diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 77dd142a7..cb5fd3da7 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -13,53 +13,54 @@ namespace Discord; -use Discord\Exceptions\IntentException; -use Discord\Factory\Factory; -use Discord\Helpers\BigInt; -use Discord\Helpers\CacheConfig; -use Discord\Helpers\RegisteredCommand; -use Discord\Http\Drivers\React; -use Discord\Http\Endpoint; +use Monolog\Level; use Discord\Http\Http; -use Discord\Parts\Channel\Channel; -use Discord\Parts\Guild\Guild; -use Discord\Parts\OAuth\Application; use Discord\Parts\Part; -use Discord\Parts\User\Activity; +use Discord\Voice\Voice; +use React\EventLoop\Loop; +use Discord\Http\Endpoint; +use Discord\WebSockets\Op; +use Discord\Helpers\BigInt; +use React\Promise\Deferred; +use Discord\Factory\Factory; +use Discord\Parts\User\User; +use Psr\Log\LoggerInterface; +use Discord\WebSockets\Event; +use Ratchet\Client\Connector; +use Ratchet\Client\WebSocket; +use Discord\Parts\Guild\Guild; use Discord\Parts\User\Client; use Discord\Parts\User\Member; -use Discord\Parts\User\User; -use Discord\Repository\AbstractRepository; -use Discord\Repository\EmojiRepository; -use Discord\Repository\GuildRepository; -use Discord\Repository\PrivateChannelRepository; -use Discord\Repository\UserRepository; use Discord\Voice\VoiceClient; -use Discord\WebSockets\Event; -use Discord\WebSockets\Events\GuildCreate; +use Monolog\Logger as Monolog; +use Discord\Http\Drivers\React; +use Discord\WebSockets\Intents; use Discord\WebSockets\Payload; +use function React\Promise\all; +use Discord\Helpers\CacheConfig; +use Discord\Parts\User\Activity; use Discord\WebSockets\Handlers; -use Discord\WebSockets\Intents; -use Discord\WebSockets\Op; use Evenement\EventEmitterTrait; -use Monolog\Formatter\LineFormatter; +use Discord\Parts\Channel\Channel; use Monolog\Handler\StreamHandler; -use Monolog\Logger as Monolog; -use Psr\Log\LoggerInterface; -use Ratchet\Client\Connector; -use Ratchet\Client\WebSocket; -use Ratchet\RFC6455\Messaging\Message; -use React\EventLoop\Loop; use React\EventLoop\LoopInterface; +use function React\Async\coroutine; use React\EventLoop\TimerInterface; -use React\Promise\Deferred; use React\Promise\PromiseInterface; +use Discord\Parts\OAuth\Application; +use Monolog\Formatter\LineFormatter; +use Discord\Helpers\RegisteredCommand; +use Discord\Repository\UserRepository; +use Ratchet\RFC6455\Messaging\Message; +use Discord\Exceptions\IntentException; +use Discord\Repository\EmojiRepository; +use Discord\Repository\GuildRepository; +use Discord\Repository\AbstractRepository; +use Discord\WebSockets\Events\GuildCreate; use React\Socket\Connector as SocketConnector; +use Discord\Repository\PrivateChannelRepository; use Symfony\Component\OptionsResolver\OptionsResolver; -use function React\Async\coroutine; -use function React\Promise\all; - /** * The Discord client class. * @@ -81,7 +82,6 @@ * @property PrivateChannelRepository $private_channels * @property SoundRepository $sounds * @property UserRepository $users - */ class Discord { @@ -108,13 +108,6 @@ class Discord */ protected $logger; - /** - * An array of loggers for voice clients. - * - * @var ?LoggerInterface[] Loggers. - */ - protected $voiceLoggers = []; - /** * An array of options passed to the client. * @@ -192,13 +185,6 @@ class Discord */ protected $sessionId; - /** - * An array of voice clients that are currently connected. - * - * @var array Voice Clients. - */ - protected $voiceClients = []; - /** * An array of large guilds that need to be requested for members. * @@ -347,6 +333,13 @@ class Discord */ private $application_commands; + /** + * The voice handler, of clients and packets. + * + * @var Voice + */ + public Voice $voice; + /** * Creates a Discord client instance. * @@ -407,9 +400,9 @@ public function __construct(array $options = []) */ protected function handleVoiceServerUpdate(Payload $data): void { - if (isset($this->voiceClients[$data->d->guild_id])) { + if (isset($this->voice->clients[$data->d->guild_id])) { $this->logger->debug('voice server update received', ['guild' => $data->d->guild_id, 'data' => $data->d]); - $this->voiceClients[$data->d->guild_id]->handleVoiceServerChange((array) $data->d); + $this->voice->clients[$data->d->guild_id]->handleVoiceServerChange((array) $data->d); } } @@ -588,9 +581,9 @@ protected function handleGuildMembersChunk(Payload $data): void */ protected function handleVoiceStateUpdate(Payload $data): void { - if (isset($this->voiceClients[$data->d->guild_id])) { + if (isset($this->voice->clients[$data->d->guild_id])) { $this->logger->debug('voice state update received', ['guild' => $data->d->guild_id, 'data' => $data->d]); - $this->voiceClients[$data->d->guild_id]->handleVoiceStateUpdate($data->d); + $this->voice->clients[$data->d->guild_id]->handleVoiceStateUpdate($data->d); } } @@ -1141,7 +1134,7 @@ public function requestSoundboardSounds(array $guildIds): void * * @param Payload|array $data Packet data. */ - protected function send(Payload|array $data, bool $force = false): void + public function send(Payload|array $data, bool $force = false): void { // Wait until payload count has been reset // Keep 5 payloads for heartbeats as required @@ -1168,6 +1161,9 @@ protected function ready() } $this->emittedInit = true; + $this->voice = new Voice($this->ws, $this->loop, $this->logger, $this?->id ?? $this->client->id); + $this->logger->info('voice class initialized'); + $this->logger->info('client is ready'); $this->emit('init', [$this]); @@ -1229,12 +1225,13 @@ public function updatePresence(?Activity $activity = null, bool $idle = false, s * Gets a voice client from a guild ID. Returns null if there is no voice client. * * @param string $guild_id The guild ID to look up. + * @deprecated Use $discord->voice->getVoiceClient() * * @return VoiceClient|null */ public function getVoiceClient(string $guild_id): ?VoiceClient { - return $this->voiceClients[$guild_id] ?? null; + return $this->voice->clients[$guild_id] ?? null; } /** @@ -1250,95 +1247,11 @@ public function getVoiceClient(string $guild_id): ?VoiceClient * @since 10.0.0 Removed argument $check that has no effect (it is always checked) * @since 4.0.0 * - * @return PromiseInterface + * @return PromiseInterface */ public function joinVoiceChannel(Channel $channel, $mute = false, $deaf = true, ?LoggerInterface $logger = null): PromiseInterface { - $deferred = new Deferred(); - - if (! $channel->isVoiceBased()) { - $deferred->reject(new \RuntimeException('Channel must allow voice.')); - - return $deferred->promise(); - } - - if (isset($this->voiceClients[$channel->guild_id])) { - $deferred->reject(new \RuntimeException('You cannot join more than one voice channel per guild.')); - - return $deferred->promise(); - } - - $data = [ - 'user_id' => $this->id, - 'deaf' => $deaf, - 'mute' => $mute, - ]; - - $voiceStateUpdate = function ($vs, $discord) use ($channel, &$data, &$voiceStateUpdate) { - if ($vs->guild_id != $channel->guild_id) { - return; // This voice state update isn't for our guild. - } - - $data['session'] = $vs->session_id; - $this->logger->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $vs->session_id]); - $this->removeListener(Event::VOICE_STATE_UPDATE, $voiceStateUpdate); - }; - - $voiceServerUpdate = function ($vs, $discord) use ($channel, &$data, &$voiceServerUpdate, $deferred, $logger) { - if ($vs->guild_id != $channel->guild_id) { - return; // This voice server update isn't for our guild. - } - - $data['token'] = $vs->token; - $data['endpoint'] = $vs->endpoint; - $data['dnsConfig'] = $discord->options['dnsConfig']; - $this->logger->info('received token and endpoint for voice session', ['guild' => $channel->guild_id, 'token' => $vs->token, 'endpoint' => $vs->endpoint]); - - if (null === $logger) { - $logger = $this->logger; - } - - $vc = new VoiceClient($this->ws, $this->loop, $channel, $logger, $data); - - $vc->once('ready', function () use ($vc, $deferred, $channel, $logger) { - $logger->info('voice client is ready'); - $this->voiceClients[$channel->guild_id] = $vc; - - $vc->setBitrate($channel->bitrate); - $logger->info('set voice client bitrate', ['bitrate' => $channel->bitrate]); - $deferred->resolve($vc); - }); - $vc->once('error', function ($e) use ($deferred, $logger) { - $logger->error('error initializing voice client', ['e' => $e->getMessage()]); - $deferred->reject($e); - }); - $vc->once('close', function () use ($channel, $logger) { - $logger->warning('voice client closed'); - unset($this->voiceClients[$channel->guild_id]); - }); - - $vc->start(); - - $this->voiceLoggers[$channel->guild_id] = $logger; - $this->removeListener(Event::VOICE_SERVER_UPDATE, $voiceServerUpdate); - }; - - $this->on(Event::VOICE_STATE_UPDATE, $voiceStateUpdate); - $this->on(Event::VOICE_SERVER_UPDATE, $voiceServerUpdate); - - $payload = Payload::new( - Op::OP_VOICE_STATE_UPDATE, - [ - 'guild_id' => $channel->guild_id, - 'channel_id' => $channel->id, - 'self_mute' => $mute, - 'self_deaf' => $deaf, - ], - ); - - $this->send($payload); - - return $deferred->promise(); + return $this->voice->createClientAndJoinChannel($channel, $this, $mute, $deaf); } /** diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php new file mode 100644 index 000000000..12e9ad03e --- /dev/null +++ b/src/Discord/Voice/Voice.php @@ -0,0 +1,152 @@ + $clients + */ + public function __construct( + protected WebSocket $botWs, + protected LoopInterface $loop, + protected LoggerInterface $logger, + protected int $botId, + public array $clients = [], + ) { + } + + public function createClientAndJoinChannel( + Channel $channel, + Discord $discord, + bool $mute = false, + bool $deaf = true, + ) + { + $deferred = new Deferred(); + + if (! $channel->isVoiceBased()) { + $deferred->reject(new \RuntimeException('Channel must allow voice.')); + + return $deferred->promise(); + } + + if (isset($this->clients[$channel->guild_id])) { + $deferred->reject(new \RuntimeException('You cannot join more than one voice channel per guild.')); + + return $deferred->promise(); + } + + $this->clients[$channel->guild_id] = ['data' => []]; + $this->clients[$channel->guild_id]['data'] = [ + 'user_id' => $this->botId, + 'deaf' => $deaf, + 'mute' => $mute, + ]; + + $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); + $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); + + $discord->send([ + 'op' => Op::OP_VOICE_STATE_UPDATE, + 'd' => [ + 'guild_id' => $channel->guild_id, + 'channel_id' => $channel->id, + 'self_mute' => $mute, + 'self_deaf' => $deaf, + ], + ]); + + return $deferred->promise(); + } + + public function getClient(string $guildId): ?VoiceClient + { + if (! isset($this->clients[$guildId])) { + return null; + } + + return $this->clients[$guildId]; + } + + private function stateUpdate($state, $channel): void + { + if ($state->guild_id != $channel->guild_id) { + return; // This voice state update isn't for our guild. + } + + $this->clients[$channel->guild_id]['data']['session'] = $state->session_id; + $this->logger->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $state->session_id]); + } + + private function serverUpdate($state, Channel $channel, $discord, Deferred $deferred): void + { + if ($state->guild_id !== $channel->guild_id) { + return; // This voice server update isn't for our guild. + } + + $data = $this->clients[$channel->guild_id]['data']; + unset($this->clients[$channel->guild_id]['data']); + + $data['token'] = $state->token; + $data['endpoint'] = $state->endpoint; + $data['dnsConfig'] = $discord->options['dnsConfig']; + + $this->logger->info('received token and endpoint for voice session', [ + 'guild' => $channel->guild_id, + 'token' => $state->token, + 'endpoint' => $state->endpoint + ]); + + $client = new VoiceClient($this->botWs, $this->loop, $channel, $this->logger, $data); + + $client->once('ready', function () use ($client, $deferred, $channel) { + $this->logger->info('voice client is ready'); + $this->clients[$channel->guild_id] = $client; + + $client->setBitrate($channel->bitrate); + + $this->logger->info('set voice client bitrate', ['bitrate' => $channel->bitrate]); + $deferred->resolve($client); + }) + ->once('error', function ($e) use ($deferred) { + $this->logger->error('error initializing voice client', ['e' => $e->getMessage()]); + $deferred->reject($e); + }) + ->once('close', function () use ($channel) { + $this->logger->warning('voice client closed'); + unset($this->clients[$channel->guild_id]); + }) + ->start(); + } + + private function sendStateUpdate(Channel $channel, bool $mute = false, bool $deaf = true): void + { + $this->botWs->send(json_encode([ + 'op' => Op::OP_VOICE_STATE_UPDATE, + 'd' => [ + 'guild_id' => $channel->guild_id, + 'channel_id' => $channel->id, + 'self_mute' => $mute, + 'self_deaf' => $deaf, + ], + ])); + } +} diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 722c94402..895bcae53 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -32,6 +32,7 @@ use React\EventLoop\TimerInterface; use React\Promise\PromiseInterface; use Ratchet\RFC6455\Messaging\Message; +use Discord\Helpers\CollectionInterface; use React\Stream\ReadableResourceStream; use Discord\Helpers\Buffer as RealBuffer; use React\Stream\ReadableStreamInterface; @@ -342,16 +343,6 @@ class VoiceClient extends EventEmitter public array $tempFiles; - /** - * Constructs the Voice Client instance. - * - * @param WebSocket $websocket The main WebSocket client. - * @param LoopInterface $loop The ReactPHP event loop. - * @param Channel $channel The channel we are connecting to. - * @param LoggerInterface $logger The logger. - * @param array $data More information related to the voice client. - */ - /** * Constructs the Voice client instance * @@ -423,10 +414,9 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->voiceWebsocket = $ws; - $firstPack = true; $ip = $port = ''; - $ws->on('message', function (Message $message) use (&$ws, &$discoverUdp, $udpfac, &$firstPack, &$ip, &$port): void { + $ws->on('message', function (Message $message) use ($udpfac, &$ip, &$port): void { $data = json_decode($message->getPayload()); $this->emit('ws-message', [$message, $this]); @@ -457,7 +447,7 @@ public function handleWebSocketConnection(WebSocket $ws): void } if (! $this->deaf && $this->secretKey) { - $this->client->on('message', fn (string $message, string $address, Socket $client) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->logger))); + $this->client->on('message', fn (string $message) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->logger))); } break; @@ -545,9 +535,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('connected to voice UDP'); $this->client = $client; - $this->loop->addTimer(0.1, function () use ($buffer) { - $this->client->send((string) $buffer); - }); + $this->loop->addTimer(0.1, fn () => $this->client->send($buffer->__toString())); $this->udpHeartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { $buffer = new Buffer(9); @@ -561,12 +549,9 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('sent UDP heartbeat'); }); - $client->on('error', function ($e): void { - $this->emit('udp-error', [$e]); - }); - - $decodeUDP = function ($message) use (&$decodeUDP, $client, &$ip, &$port): void { + $client->on('error', fn ($e): void => $this->emit('udp-error', [$e])); + $decodeUDP = function ($message) use (&$ip, &$port): void { /** * Unpacks the message into an array. * @@ -582,10 +567,6 @@ public function handleWebSocketConnection(WebSocket $ws): void */ $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); - # Commented out since it's not being used as of yet - # $typeRequest = $unpackedMessageArray['Type1']; - # $typeResponse = $unpackedMessageArray['Type2']; - # $length = $unpackedMessageArray['Length']; $this->ssrc = $unpackedMessageArray['SSRC']; $ip = $unpackedMessageArray['Address']; $port = $unpackedMessageArray['Port']; @@ -610,7 +591,6 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); }); - break; } default: @@ -1578,10 +1558,10 @@ protected function handleAudioData(VoicePacket $voicePacket): void private function monitorProcessExit(Process $process, $ss, callable $createDecoder): void { // Store the process ID - $pid = $process->getPid(); + // $pid = $process->getPid(); // Check every second if the process is still running - $timer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss, &$createDecoder, &$timer, $pid) { + $timer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss, &$createDecoder, &$timer) { // Check if the process is still running if (!$process->isRunning()) { // Get the exit code From f36706667fda3fd73fa0749c3d6b555d993ce4c0 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:09:36 -0400 Subject: [PATCH 026/121] Reorganize imports --- src/Discord/Discord.php | 68 ++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index cb5fd3da7..205177495 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -13,54 +13,54 @@ namespace Discord; -use Monolog\Level; -use Discord\Http\Http; -use Discord\Parts\Part; -use Discord\Voice\Voice; -use React\EventLoop\Loop; -use Discord\Http\Endpoint; -use Discord\WebSockets\Op; -use Discord\Helpers\BigInt; -use React\Promise\Deferred; +use Discord\Exceptions\IntentException; use Discord\Factory\Factory; -use Discord\Parts\User\User; -use Psr\Log\LoggerInterface; -use Discord\WebSockets\Event; -use Ratchet\Client\Connector; -use Ratchet\Client\WebSocket; +use Discord\Helpers\CacheConfig; +use Discord\Helpers\BigInt; +use Discord\Helpers\RegisteredCommand; +use Discord\Http\Drivers\React; +use Discord\Http\Endpoint; +use Discord\Http\Http; +use Discord\Repository\AbstractRepository; +use Discord\Repository\EmojiRepository; +use Discord\Repository\GuildRepository; +use Discord\Repository\PrivateChannelRepository; +use Discord\Repository\UserRepository; +use Discord\Parts\Channel\Channel; use Discord\Parts\Guild\Guild; +use Discord\Parts\OAuth\Application; +use Discord\Parts\Part; +use Discord\Parts\User\Activity; use Discord\Parts\User\Client; use Discord\Parts\User\Member; +use Discord\Parts\User\User; +use Discord\Voice\Voice; use Discord\Voice\VoiceClient; -use Monolog\Logger as Monolog; -use Discord\Http\Drivers\React; -use Discord\WebSockets\Intents; -use Discord\WebSockets\Payload; -use function React\Promise\all; -use Discord\Helpers\CacheConfig; -use Discord\Parts\User\Activity; +use Discord\WebSockets\Event; +use Discord\WebSockets\Events\GuildCreate; use Discord\WebSockets\Handlers; +use Discord\WebSockets\Intents; +use Discord\WebSockets\Op; use Evenement\EventEmitterTrait; -use Discord\Parts\Channel\Channel; +use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; +use Monolog\Level; +use Monolog\Logger as Monolog; +use Psr\Log\LoggerInterface; +use Ratchet\Client\Connector; +use Ratchet\Client\WebSocket; +use Ratchet\RFC6455\Messaging\Message; +use React\EventLoop\Loop; use React\EventLoop\LoopInterface; -use function React\Async\coroutine; use React\EventLoop\TimerInterface; +use React\Promise\Deferred; use React\Promise\PromiseInterface; -use Discord\Parts\OAuth\Application; -use Monolog\Formatter\LineFormatter; -use Discord\Helpers\RegisteredCommand; -use Discord\Repository\UserRepository; -use Ratchet\RFC6455\Messaging\Message; -use Discord\Exceptions\IntentException; -use Discord\Repository\EmojiRepository; -use Discord\Repository\GuildRepository; -use Discord\Repository\AbstractRepository; -use Discord\WebSockets\Events\GuildCreate; use React\Socket\Connector as SocketConnector; -use Discord\Repository\PrivateChannelRepository; use Symfony\Component\OptionsResolver\OptionsResolver; +use function React\Async\coroutine; +use function React\Promise\all; + /** * The Discord client class. * From 4b42a76437331896cff35f855d834cb407844499 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:11:10 -0400 Subject: [PATCH 027/121] Imports --- src/Discord/Voice/Voice.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index 12e9ad03e..a3dffef3e 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -2,16 +2,16 @@ namespace Discord\Voice; +use Evenement\EventEmitterTrait; use Discord\Discord; +use Discord\Parts\Channel\Channel; +use Discord\Voice\VoiceClient; +use Discord\WebSockets\Event; use Discord\WebSockets\Op; -use React\Promise\Deferred; use Psr\Log\LoggerInterface; -use Discord\WebSockets\Event; use Ratchet\Client\WebSocket; -use Discord\Voice\VoiceClient; -use Evenement\EventEmitterTrait; -use Discord\Parts\Channel\Channel; use React\EventLoop\LoopInterface; +use React\Promise\Deferred; final class Voice { From dbae1ddc43f1583eed34211fca617876460d20dd Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:17:51 -0400 Subject: [PATCH 028/121] Imports --- src/Discord/Voice/VoiceClient.php | 45 +++++++++++++++---------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 895bcae53..b7cb91894 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -13,39 +13,36 @@ namespace Discord\Voice; -use Throwable; +use Discord\Exceptions\Voice\ClientNotReadyException; +use Discord\Exceptions\Voice\AudioAlreadyPlayingException; +use Discord\Helpers\Buffer as RealBuffer; +use Discord\Exceptions\FFmpegNotFoundException; +use Discord\Exceptions\LibSodiumNotFoundException; +use Discord\Helpers\Collection; +use Discord\Helpers\CollectionInterface; +use Discord\Exceptions\OutdatedDCAException; +use Discord\Exceptions\FileNotFoundException; +use Discord\Parts\Channel\Channel; +use Discord\Voice\VoicePacket; +use Discord\Voice\ReceiveStream; use Discord\WebSockets\Op; -use React\Datagram\Socket; use Evenement\EventEmitter; -use React\Promise\Deferred; use Psr\Log\LoggerInterface; -use React\Dns\Config\Config; +use Ratchet\Client\Connector as WsFactory; use Ratchet\Client\WebSocket; -use Discord\Parts\User\Member; -use Discord\Voice\VoicePacket; -use Discord\Helpers\Collection; -use Discord\WebSockets\Payload; +use Ratchet\RFC6455\Messaging\Message; use React\ChildProcess\Process; -use Discord\Voice\ReceiveStream; -use Discord\Parts\Channel\Channel; +use React\Datagram\Factory as DatagramFactory; +use React\Datagram\Socket; +use React\Dns\Config\Config; +use React\Dns\Resolver\Factory as DNSFactory; use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; +use React\Promise\Deferred; use React\Promise\PromiseInterface; -use Ratchet\RFC6455\Messaging\Message; -use Discord\Helpers\CollectionInterface; -use React\Stream\ReadableResourceStream; -use Discord\Helpers\Buffer as RealBuffer; -use React\Stream\ReadableStreamInterface; -use Ratchet\Client\Connector as WsFactory; -use Discord\Exceptions\OutdatedDCAException; -use Discord\Exceptions\FileNotFoundException; -use React\Dns\Resolver\Factory as DNSFactory; -use React\Datagram\Factory as DatagramFactory; -use Discord\Exceptions\FFmpegNotFoundException; -use Discord\Exceptions\LibSodiumNotFoundException; use React\Stream\ReadableResourceStream as Stream; -use Discord\Exceptions\Voice\ClientNotReadyException; -use Discord\Exceptions\Voice\AudioAlreadyPlayingException; +use React\Stream\ReadableStreamInterface; +use Throwable; /** * The Discord voice client. From 75fd8b7d79ea46f66f17f28cd67ba9c993b72f39 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:30:11 -0400 Subject: [PATCH 029/121] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e198e89d5..213be651a 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ composer.lock phpunit.log /.phpunit* /coverage +/.vs From b311875c7503629e3575c8c496e55f1f4faadad9 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:30:11 -0400 Subject: [PATCH 030/121] Update .gitignore From 8fadcd8cfdae1dcc71ccdac2b472872cae963e79 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Wed, 9 Apr 2025 10:34:04 -0400 Subject: [PATCH 031/121] Import alias and broken function --- src/Discord/Voice/VoiceClient.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index b7cb91894..938b14084 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -546,7 +546,10 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('sent UDP heartbeat'); }); - $client->on('error', fn ($e): void => $this->emit('udp-error', [$e])); + $client->on('error', function ($e): void + { + $this->emit('udp-error', [$e]); + }); $decodeUDP = function ($message) use (&$ip, &$port): void { /** @@ -838,7 +841,7 @@ public function playOggStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new ReadableResourceStream($stream, $this->loop); + $stream = new Stream($stream, $this->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -954,7 +957,7 @@ public function playDCAStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new ReadableResourceStream($stream, $this->loop); + $stream = new Stream($stream, $this->loop); } if (! ($stream instanceof ReadableStreamInterface)) { From 65c6de987f4a2816a819918a4dd1147efbe14dda Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 9 May 2025 10:50:08 -0400 Subject: [PATCH 032/121] VOICE_CLIENT_CONNECT deprecated Use VOICE_CLIENTS_CONNECT instead --- src/Discord/WebSockets/Op.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Discord/WebSockets/Op.php b/src/Discord/WebSockets/Op.php index 3b8782057..319adb706 100644 --- a/src/Discord/WebSockets/Op.php +++ b/src/Discord/WebSockets/Op.php @@ -89,6 +89,7 @@ class Op public const VOICE_RESUMED = 9; /** One or more clients have connected to the voice channel */ public const VOICE_CLIENTS_CONNECT = 11; + public const VOICE_CLIENT_CONNECT = 11; // Deprecated, used VOICE_CLIENTS_CONNECT instead /** A client has disconnected from the voice channel. */ public const VOICE_CLIENT_DISCONNECT = 13; /** Was not documented within the op codes and statuses*/ From 1885f722b09693fcee91400eb1ae14dfb1625583 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 16 May 2025 12:41:39 -0400 Subject: [PATCH 033/121] Imports --- src/Discord/Discord.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 205177495..f42eebd12 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -15,8 +15,8 @@ use Discord\Exceptions\IntentException; use Discord\Factory\Factory; -use Discord\Helpers\CacheConfig; use Discord\Helpers\BigInt; +use Discord\Helpers\CacheConfig; use Discord\Helpers\RegisteredCommand; use Discord\Http\Drivers\React; use Discord\Http\Endpoint; From 0112d2f47cf46f5b385cc28dfe02819bd0996be4 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 16:11:13 +0100 Subject: [PATCH 034/121] Updates usage of trafficcophp/bytebuffer to a locally ovewritten class Adds enum for format pack --- composer.json | 8 +- src/Discord/Helpers/FormatPackEnum.php | 170 +++++++++++++++ src/Discord/Voice/Buffer.php | 282 ++++++++++++++++++++++++- 3 files changed, 452 insertions(+), 8 deletions(-) create mode 100644 src/Discord/Helpers/FormatPackEnum.php diff --git a/composer.json b/composer.json index ffc50b481..5fa90f497 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "ratchet/pawl": "^0.4.3", "react/datagram": "^1.8", "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0", - "trafficcophp/bytebuffer": "^0.4", + "trafficcophp/bytebuffer": "^0.3", "monolog/monolog": "^2.1.1 || ^3.0", "react/event-loop": "^1.2", "ext-json": "*", @@ -42,12 +42,6 @@ "symfony/cache": "^5.4", "laravel/pint": "^1.21" }, - "repositories": [ - { - "type": "github", - "url": "https://github.com/alexandre433/byte-buffer" - } - ], "autoload": { "files": [ "src/Discord/functions.php" diff --git a/src/Discord/Helpers/FormatPackEnum.php b/src/Discord/Helpers/FormatPackEnum.php new file mode 100644 index 000000000..c8d4cef03 --- /dev/null +++ b/src/Discord/Helpers/FormatPackEnum.php @@ -0,0 +1,170 @@ + 2, + self::N, self::V => 4, + self::c, self::C => 1, + default => throw new \InvalidArgumentException('Invalid format pack'), + }; + } +} diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 3b5d424a8..a6f33c46f 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -14,8 +14,9 @@ namespace Discord\Voice; use ArrayAccess; +use SplFixedArray; +use Discord\Helpers\FormatPackEnum; use TrafficCophp\ByteBuffer\Buffer as BaseBuffer; -use TrafficCophp\ByteBuffer\FormatPackEnum; /** * A Byte Buffer similar to Buffer in NodeJS. @@ -24,6 +25,285 @@ */ class Buffer extends BaseBuffer implements ArrayAccess { + protected SplFixedArray $buffer; + + public function __construct($argument) + { + match (true) { + is_string($argument) => $this->initializeStructs(strlen($argument), $argument), + is_int($argument) => $this->initializeStructs($argument, pack(FormatPackEnum::x->value . "$argument")), + default => throw new \InvalidArgumentException('Constructor argument must be an binary string or integer') + }; + } + + public function __toString(): string + { + $buf = ''; + foreach ($this->buffer as $bytes) { + $buf .= $bytes; + } + return $buf; + } + + public static function make($argument): static + { + return new static($argument); + } + + protected function initializeStructs($length, $content): void + { + $this->buffer = new SplFixedArray($length); + for ($i = 0; $i < $length; $i++) { + $this->buffer[$i] = $content[$i]; + } + } + + /** + * Inserts a value into the buffer at the specified offset. + * + * @param FormatPackEnum|string $format + * @param mixed $value + * @param int $offset + * @param mixed $length + * @return Buffer + */ + protected function insert($format, $value, $offset, $length): self + { + $bytes = pack($format?->value ?? $format, $value); + + if (null === $length) { + $length = strlen($bytes); + } + + for ($i = 0; $i < strlen($bytes); $i++) { + $this->buffer[$offset++] = $bytes[$i]; + } + + return $this; + } + + /** + * Extracts a value from the buffer at the specified offset. + * + * @param FormatPackEnum|string $format + * @param int $offset + * @param int $length + * @return mixed + */ + protected function extract($format, $offset, $length) + { + $encoded = ''; + for ($i = 0; $i < $length; $i++) { + $encoded .= $this->buffer->offsetGet($offset + $i); + } + + if ($format == FormatPackEnum::N && PHP_INT_SIZE <= 4) { + [, $h, $l] = unpack('n*', $encoded); + $result = $l + $h * 0x010000; + } elseif ($format == FormatPackEnum::V && PHP_INT_SIZE <= 4) { + [, $h, $l] = unpack('v*', $encoded); + $result = $h + $l * 0x010000; + } else { + [, $result] = unpack($format?->value ?? $format, $encoded); + } + + return $result; + } + + /** + * Checks if the actual value exceeds the expected maximum size. + * + * @param mixed $excpectedMax + * @param mixed $actual + * @throws \InvalidArgumentException + * @return static + */ + protected function checkForOverSize($excpectedMax, $actual) + { + if ($actual > $excpectedMax) { + throw new \InvalidArgumentException(sprintf('%d exceeded limit of %d', $actual, $excpectedMax)); + } + + return $this; + } + + public function length(): int + { + return $this->buffer->getSize(); + } + + public function getLastEmptyPosition(): int + { + foreach($this->buffer as $key => $value) { + if (empty(trim($value))) { + return $key; + } + } + + return 0; + } + + /** + * Writes a string to the buffer at the specified offset. + * + * @param string $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function write($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $length = strlen($value); + $this->insert('a' . $length, $value, $offset, $length); + + return $this; + } + + /** + * Writes an 8-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt8($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::C; + $this->checkForOverSize(0xff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 16-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt16BE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::n; + $this->checkForOverSize(0xffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 16-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt16LE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::v; + $this->checkForOverSize(0xffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 32-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt32BE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::N; + $this->checkForOverSize(0xffffffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Writes a 32-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + public function writeInt32LE($value, $offset = null) + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::V; + $this->checkForOverSize(0xffffffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + /** + * Reads a string from the buffer at the specified offset. + * + * @param int $offset The offset to read from. + * @param int $length The length of the string to read. + * @return string The data read. + */ + public function read($offset, $length) + { + return $this->extract('a' . $length, $offset, $length); + } + + public function readInt8($offset) + { + $format = FormatPackEnum::C; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt16BE($offset) + { + $format = FormatPackEnum::n; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt16LE($offset) + { + $format = FormatPackEnum::v; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt32BE($offset) + { + $format = FormatPackEnum::N; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt32LE($offset) + { + $format = FormatPackEnum::V; + return $this->extract($format, $offset, $format->getLength()); + } + /** * Writes a 32-bit unsigned integer with big endian. * From 5f36bada4e2c9a37b307d09a707a809f01027a42 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 16:24:37 +0100 Subject: [PATCH 035/121] Updates function according to master change --- src/Discord/Voice/Voice.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index a3dffef3e..62eaa5264 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -2,6 +2,7 @@ namespace Discord\Voice; +use Discord\WebSockets\Payload; use Evenement\EventEmitterTrait; use Discord\Discord; use Discord\Parts\Channel\Channel; @@ -64,15 +65,15 @@ public function createClientAndJoinChannel( $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); - $discord->send([ - 'op' => Op::OP_VOICE_STATE_UPDATE, - 'd' => [ + $discord->send(Payload::new( + Op::OP_VOICE_STATE_UPDATE, + [ 'guild_id' => $channel->guild_id, 'channel_id' => $channel->id, 'self_mute' => $mute, 'self_deaf' => $deaf, ], - ]); + )); return $deferred->promise(); } From a43a49899986314ec200b0694750969f0d031d91 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 22:22:50 +0100 Subject: [PATCH 036/121] Removes variable overriding --- src/Discord/Voice/Buffer.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index a6f33c46f..2b355a92b 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -25,8 +25,6 @@ */ class Buffer extends BaseBuffer implements ArrayAccess { - protected SplFixedArray $buffer; - public function __construct($argument) { match (true) { From 7e0ab9502c4a3f77f2f68b2eff987f3f6f8cb8e6 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 22:22:57 +0100 Subject: [PATCH 037/121] Fixes import --- src/Discord/Voice/VoicePacket.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index d73ac682d..37f7129e9 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -14,7 +14,7 @@ namespace Discord\Voice; use Monolog\Logger; -use TrafficCophp\ByteBuffer\FormatPackEnum; +use Discord\Helpers\FormatPackEnum; /** * A voice packet received from Discord. From d4ff0da0e605f2e6452082e50cfc481fe3f75f94 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 22:25:53 +0100 Subject: [PATCH 038/121] Fixes issue on variable type --- src/Discord/Discord.php | 18 +++++++++--------- src/Discord/Voice/VoiceClient.php | 17 +++++++++-------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index f42eebd12..f1e8c91c5 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -21,11 +21,6 @@ use Discord\Http\Drivers\React; use Discord\Http\Endpoint; use Discord\Http\Http; -use Discord\Repository\AbstractRepository; -use Discord\Repository\EmojiRepository; -use Discord\Repository\GuildRepository; -use Discord\Repository\PrivateChannelRepository; -use Discord\Repository\UserRepository; use Discord\Parts\Channel\Channel; use Discord\Parts\Guild\Guild; use Discord\Parts\OAuth\Application; @@ -34,6 +29,11 @@ use Discord\Parts\User\Client; use Discord\Parts\User\Member; use Discord\Parts\User\User; +use Discord\Repository\AbstractRepository; +use Discord\Repository\EmojiRepository; +use Discord\Repository\GuildRepository; +use Discord\Repository\PrivateChannelRepository; +use Discord\Repository\UserRepository; use Discord\Voice\Voice; use Discord\Voice\VoiceClient; use Discord\WebSockets\Event; @@ -41,7 +41,10 @@ use Discord\WebSockets\Handlers; use Discord\WebSockets\Intents; use Discord\WebSockets\Op; +use Discord\WebSockets\Payload; use Evenement\EventEmitterTrait; +use function React\Async\coroutine; +use function React\Promise\all; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Level; @@ -58,9 +61,6 @@ use React\Socket\Connector as SocketConnector; use Symfony\Component\OptionsResolver\OptionsResolver; -use function React\Async\coroutine; -use function React\Promise\all; - /** * The Discord client class. * @@ -1161,7 +1161,7 @@ protected function ready() } $this->emittedInit = true; - $this->voice = new Voice($this->ws, $this->loop, $this->logger, $this?->id ?? $this->client->id); + $this->voice = new Voice($this->ws, $this->loop, $this->logger, (int) $this?->id ?? $this->client->id); $this->logger->info('voice class initialized'); $this->logger->info('client is ready'); diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 938b14084..1a5b13ed0 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -13,19 +13,20 @@ namespace Discord\Voice; -use Discord\Exceptions\Voice\ClientNotReadyException; -use Discord\Exceptions\Voice\AudioAlreadyPlayingException; -use Discord\Helpers\Buffer as RealBuffer; use Discord\Exceptions\FFmpegNotFoundException; +use Discord\Exceptions\FileNotFoundException; use Discord\Exceptions\LibSodiumNotFoundException; +use Discord\Exceptions\OutdatedDCAException; +use Discord\Exceptions\Voice\AudioAlreadyPlayingException; +use Discord\Exceptions\Voice\ClientNotReadyException; +use Discord\Helpers\Buffer as RealBuffer; use Discord\Helpers\Collection; use Discord\Helpers\CollectionInterface; -use Discord\Exceptions\OutdatedDCAException; -use Discord\Exceptions\FileNotFoundException; use Discord\Parts\Channel\Channel; -use Discord\Voice\VoicePacket; use Discord\Voice\ReceiveStream; +use Discord\Voice\VoicePacket; use Discord\WebSockets\Op; +use Discord\WebSockets\Payload; use Evenement\EventEmitter; use Psr\Log\LoggerInterface; use Ratchet\Client\Connector as WsFactory; @@ -209,9 +210,9 @@ class VoiceClient extends EventEmitter /** * The time we started sending packets. * - * @var int The time we started sending packets. + * @var null|int|float The time we started sending packets. */ - protected ?int $startTime; + protected $startTime; /** * The stream time of the last packet. From f2f5e532d40620395383ba7eafd05fee7de19a9c Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 1 Jun 2025 23:22:40 +0100 Subject: [PATCH 039/121] Fixes payload sent, since it's using v8 --- src/Discord/Voice/VoiceClient.php | 13 +++++++------ src/Discord/WebSockets/Payload.php | 8 +++++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 1a5b13ed0..a155037d4 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -460,7 +460,10 @@ public function handleWebSocketConnection(WebSocket $ws): void $sendHeartbeat = function () { $this->send(Payload::new( Op::VOICE_HEARTBEAT, - (int) microtime(true) + [ + 't' => (int) microtime(true), + 'seq_ack' => 10 + ] )); $this->logger->debug('sending heartbeat'); $this->emit('ws-heartbeat', []); @@ -528,6 +531,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $buffer[1] = "\x01"; $buffer[3] = "\x46"; $buffer->writeUInt32BE($this->ssrc, 4); + /** @var PromiseInterface */ $udpfac->createClient("{$data->d->ip}:{$this->udpPort}")->then(function (Socket $client) use (&$ip, &$port, $buffer): void { $this->logger->debug('connected to voice UDP'); @@ -547,10 +551,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->logger->debug('sent UDP heartbeat'); }); - $client->on('error', function ($e): void - { - $this->emit('udp-error', [$e]); - }); + $client->on('error', fn ($e) => $this->emit('udp-error', [$e])); $decodeUDP = function ($message) use (&$ip, &$port): void { /** @@ -618,7 +619,7 @@ public function handleWebSocketConnection(WebSocket $ws): void ], ); - $this->logger->debug('sending identify', ['packet' => $payload]); + $this->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); $this->send($payload); $this->sentLoginFrame = true; diff --git a/src/Discord/WebSockets/Payload.php b/src/Discord/WebSockets/Payload.php index 05379948a..24be5c85c 100644 --- a/src/Discord/WebSockets/Payload.php +++ b/src/Discord/WebSockets/Payload.php @@ -49,7 +49,13 @@ public function __construct(int $op, $d = null, ?int $s = null, ?string $t = nul $this->t = $t; } - public static function new(int $op, $d = null, ?int $s = null, ?string $t = null): self + public static function new( + int $op, + $d = null, + ?int $s = null, + // token - add attribute + ?string $t = null + ): self { return new self($op, $d, $s, $t); } From 10e2f0980045db113780840f37d7e84e3072c7a3 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 04:37:05 -0400 Subject: [PATCH 040/121] Move import of functions below import of classes --- src/Discord/Discord.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 6ecf62bac..f0a19f5e9 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -43,8 +43,6 @@ use Discord\WebSockets\Op; use Discord\WebSockets\Payload; use Evenement\EventEmitterTrait; -use function React\Async\coroutine; -use function React\Promise\all; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Level; @@ -61,6 +59,9 @@ use React\Socket\Connector as SocketConnector; use Symfony\Component\OptionsResolver\OptionsResolver; +use function React\Async\coroutine; +use function React\Promise\all; + /** * The Discord client class. * From 80dcf4acb7324e723bdb18c8c17cbcc1def3de19 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:20:19 -0400 Subject: [PATCH 041/121] Make method compatible with parent methods --- src/Discord/Voice/Buffer.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 2b355a92b..2e7575bdb 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -116,7 +116,7 @@ protected function extract($format, $offset, $length) * @throws \InvalidArgumentException * @return static */ - protected function checkForOverSize($excpectedMax, $actual) + protected function checkForOverSize($excpectedMax, $actual): self { if ($actual > $excpectedMax) { throw new \InvalidArgumentException(sprintf('%d exceeded limit of %d', $actual, $excpectedMax)); @@ -148,7 +148,7 @@ public function getLastEmptyPosition(): int * @param int|null $offset The offset that the value will be written at. * @return static */ - public function write($value, $offset = null) + public function write($value, $offset = null): self { if (null === $offset) { $offset = $this->getLastEmptyPosition(); @@ -167,7 +167,7 @@ public function write($value, $offset = null) * @param int|null $offset The offset that the value will be written at. * @return static */ - public function writeInt8($value, $offset = null) + public function writeInt8($value, $offset = null): self { if (null === $offset) { $offset = $this->getLastEmptyPosition(); @@ -187,7 +187,7 @@ public function writeInt8($value, $offset = null) * @param int|null $offset The offset that the value will be written at. * @return static */ - public function writeInt16BE($value, $offset = null) + public function writeInt16BE($value, $offset = null): self { if (null === $offset) { $offset = $this->getLastEmptyPosition(); @@ -207,7 +207,7 @@ public function writeInt16BE($value, $offset = null) * @param int|null $offset The offset that the value will be written at. * @return static */ - public function writeInt16LE($value, $offset = null) + public function writeInt16LE($value, $offset = null): self { if (null === $offset) { $offset = $this->getLastEmptyPosition(); @@ -227,7 +227,7 @@ public function writeInt16LE($value, $offset = null) * @param int|null $offset The offset that the value will be written at. * @return static */ - public function writeInt32BE($value, $offset = null) + public function writeInt32BE($value, $offset = null): self { if (null === $offset) { $offset = $this->getLastEmptyPosition(); @@ -247,7 +247,8 @@ public function writeInt32BE($value, $offset = null) * @param int|null $offset The offset that the value will be written at. * @return static */ - public function writeInt32LE($value, $offset = null) + #[\Override] + public function writeInt32LE($value, $offset = null): self { if (null === $offset) { $offset = $this->getLastEmptyPosition(); From 82fa7f9c093a98e918ef214d06c2ada9c72cce7c Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:21:58 -0400 Subject: [PATCH 042/121] Fix typing for VoiceClient::startTime --- src/Discord/Voice/VoiceClient.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 9c9885fb5..f2d5f1839 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -210,9 +210,9 @@ class VoiceClient extends EventEmitter /** * The time we started sending packets. * - * @var null|int|float The time we started sending packets. + * @var float|int|null The time we started sending packets. */ - protected ?int $startTime; + protected float|int|null $startTime; /** * The stream time of the last packet. From 3d5d651c9f910d9a63742a20602b5d7fa4809672 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:23:13 -0400 Subject: [PATCH 043/121] Discord imports --- src/Discord/Discord.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index f0a19f5e9..3775f4a15 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -21,11 +21,6 @@ use Discord\Http\Drivers\React; use Discord\Http\Endpoint; use Discord\Http\Http; -use Discord\Repository\AbstractRepository; -use Discord\Repository\EmojiRepository; -use Discord\Repository\GuildRepository; -use Discord\Repository\PrivateChannelRepository; -use Discord\Repository\UserRepository; use Discord\Parts\Channel\Channel; use Discord\Parts\Guild\Guild; use Discord\Parts\OAuth\Application; @@ -34,6 +29,11 @@ use Discord\Parts\User\Client; use Discord\Parts\User\Member; use Discord\Parts\User\User; +use Discord\Repository\AbstractRepository; +use Discord\Repository\EmojiRepository; +use Discord\Repository\GuildRepository; +use Discord\Repository\PrivateChannelRepository; +use Discord\Repository\UserRepository; use Discord\Voice\Voice; use Discord\Voice\VoiceClient; use Discord\WebSockets\Event; @@ -45,7 +45,6 @@ use Evenement\EventEmitterTrait; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; -use Monolog\Level; use Monolog\Logger as Monolog; use Psr\Log\LoggerInterface; use Ratchet\Client\Connector; From db923973ff00761933020bd0fb762063a1fae2e2 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:24:22 -0400 Subject: [PATCH 044/121] Buffer imports --- src/Discord/Voice/Buffer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 2e7575bdb..2369be9ee 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -14,8 +14,8 @@ namespace Discord\Voice; use ArrayAccess; -use SplFixedArray; use Discord\Helpers\FormatPackEnum; +use SplFixedArray; use TrafficCophp\ByteBuffer\Buffer as BaseBuffer; /** From 93f28c6998958faf64d85fd6c17808fd4d057bed Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:24:36 -0400 Subject: [PATCH 045/121] Remove ReceiveStream imports --- src/Discord/Voice/ReceiveStream.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php index 4162980bc..659b83c05 100644 --- a/src/Discord/Voice/ReceiveStream.php +++ b/src/Discord/Voice/ReceiveStream.php @@ -11,10 +11,6 @@ namespace Discord\Voice; -use Evenement\EventEmitter; -use React\Stream\DuplexStreamInterface; -use React\Stream\WritableStreamInterface; - /** * Handles recieving audio from Discord. * From c70e42f4ce2fc4215d5d741154cff6f12b12b240 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:25:21 -0400 Subject: [PATCH 046/121] Voice imports --- src/Discord/Voice/Voice.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index 62eaa5264..b4881f5ce 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -2,13 +2,13 @@ namespace Discord\Voice; -use Discord\WebSockets\Payload; -use Evenement\EventEmitterTrait; use Discord\Discord; use Discord\Parts\Channel\Channel; use Discord\Voice\VoiceClient; use Discord\WebSockets\Event; use Discord\WebSockets\Op; +use Discord\WebSockets\Payload; +use Evenement\EventEmitterTrait; use Psr\Log\LoggerInterface; use Ratchet\Client\WebSocket; use React\EventLoop\LoopInterface; From 6e0025d0d03a92dd89fe60b69b34991574c5e08f Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:26:57 -0400 Subject: [PATCH 047/121] VoiceClient imports --- src/Discord/Voice/VoiceClient.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index f2d5f1839..199837afc 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -13,15 +13,15 @@ namespace Discord\Voice; -use Discord\Exceptions\Voice\ClientNotReadyException; -use Discord\Exceptions\Voice\AudioAlreadyPlayingException; use Discord\Exceptions\FFmpegNotFoundException; use Discord\Exceptions\FileNotFoundException; use Discord\Exceptions\LibSodiumNotFoundException; use Discord\Exceptions\OutdatedDCAException; +use Discord\Exceptions\Voice\ClientNotReadyException; +use Discord\Exceptions\Voice\AudioAlreadyPlayingException; use Discord\Helpers\Buffer as RealBuffer; use Discord\Helpers\Collection; -use Discord\Helpers\CollectionInterface; +use Discord\Helpers\ExCollectionInterface; use Discord\Parts\Channel\Channel; use Discord\Voice\VoicePacket; use Discord\Voice\ReceiveStream; @@ -43,7 +43,6 @@ use React\Promise\PromiseInterface; use React\Stream\ReadableResourceStream as Stream; use React\Stream\ReadableStreamInterface; -use Throwable; /** * The Discord voice client. @@ -210,7 +209,7 @@ class VoiceClient extends EventEmitter /** * The time we started sending packets. * - * @var float|int|null The time we started sending packets. + * @var float The time we started sending packets. */ protected float|int|null $startTime; @@ -588,7 +587,7 @@ public function handleWebSocketConnection(WebSocket $ws): void }; $client->once('message', $decodeUDP); - }, function (Throwable $e): void { + }, function (\Throwable $e): void { $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); }); From 8f420f1593b5f51866561a7d1b64e4508202b2ae Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:36:30 -0400 Subject: [PATCH 048/121] Helpers classes instead of dependency --- .../Helpers/ByteBuffer/AbstractBuffer.php | 26 ++ src/Discord/Helpers/ByteBuffer/Buffer.php | 227 ++++++++++++++++++ .../Helpers/ByteBuffer/FormatPackEnum.php | 180 ++++++++++++++ .../Helpers/ByteBuffer/ReadableBuffer.php | 31 +++ .../Helpers/ByteBuffer/WriteableBuffer.php | 66 +++++ src/Discord/Voice/Buffer.php | 16 +- 6 files changed, 537 insertions(+), 9 deletions(-) create mode 100644 src/Discord/Helpers/ByteBuffer/AbstractBuffer.php create mode 100644 src/Discord/Helpers/ByteBuffer/Buffer.php create mode 100644 src/Discord/Helpers/ByteBuffer/FormatPackEnum.php create mode 100644 src/Discord/Helpers/ByteBuffer/ReadableBuffer.php create mode 100644 src/Discord/Helpers/ByteBuffer/WriteableBuffer.php diff --git a/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php new file mode 100644 index 000000000..68b7121eb --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php @@ -0,0 +1,26 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers\ByteBuffer; + +/** + * @author alexandre433. + */ +abstract class AbstractBuffer implements ReadableBuffer, WriteableBuffer +{ + abstract public function __construct($argument); + + abstract public function __toString(): string; + + abstract public function length(): int; + + abstract public function getLastEmptyPosition(): int; +} diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php new file mode 100644 index 000000000..3b2810832 --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -0,0 +1,227 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers\ByteBuffer; + +/** + * Helper class for handling binary data. + * + * @author alexandre433 + * + * @throws \InvalidArgumentException If invalid arguments are provided or buffer overflows. + */ +class Buffer extends AbstractBuffer +{ + protected \SplFixedArray $buffer; + + public function __construct($argument) + { + match (true) { + is_string($argument) => $this->initializeStructs(strlen($argument), $argument), + is_int($argument) => $this->initializeStructs($argument, pack(FormatPackEnum::x->value . "$argument")), + default => throw new \InvalidArgumentException('Constructor argument must be an binary string or integer') + }; + } + + public function __toString(): string + { + $buf = ''; + foreach ($this->buffer as $bytes) { + $buf .= $bytes; + } + return $buf; + } + + public static function make($argument): static + { + return new static($argument); + } + + protected function initializeStructs(string $length, string $content): void + { + $this->buffer = new \SplFixedArray($length); + for ($i = 0; $i < $length; $i++) { + $this->buffer[$i] = $content[$i]; + } + } + + protected function insert(FormatPackEnum|string $format, $value, int $offset, ?int $length): self + { + $bytes = pack($format?->value ?? $format, $value); + + if (null === $length) { + $length = strlen($bytes); + } + + for ($i = 0; $i < strlen($bytes); $i++) { + $this->buffer[$offset++] = $bytes[$i]; + } + + return $this; + } + + protected function extract(FormatPackEnum|string $format, int $offset, int $length) + { + $encoded = ''; + for ($i = 0; $i < $length; $i++) { + $encoded .= $this->buffer->offsetGet($offset + $i); + } + + if ($format == FormatPackEnum::N && PHP_INT_SIZE <= 4) { + [, $h, $l] = unpack('n*', $encoded); + $result = $l + $h * 0x010000; + } elseif ($format == FormatPackEnum::V && PHP_INT_SIZE <= 4) { + [, $h, $l] = unpack('v*', $encoded); + $result = $h + $l * 0x010000; + } else { + [, $result] = unpack($format?->value ?? $format, $encoded); + } + + return $result; + } + + protected function checkForOverSize($excpectedMax, string|int $actual): self + { + if ($actual > $excpectedMax) { + throw new \InvalidArgumentException(sprintf('%d exceeded limit of %d', $actual, $excpectedMax)); + } + + return $this; + } + + public function length(): int + { + return $this->buffer->getSize(); + } + + public function getLastEmptyPosition(): int + { + foreach($this->buffer as $key => $value) { + if (empty(trim($value))) { + return $key; + } + } + + return 0; + } + + public function write($value, ?int $offset = null): self + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $length = strlen($value); + $this->insert('a' . $length, $value, $offset, $length); + + return $this; + } + + public function writeInt8($value, ?int $offset = null): self + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::C; + $this->checkForOverSize(0xff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + public function writeInt16BE($value, ?int $offset = null): self + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::n; + $this->checkForOverSize(0xffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + public function writeInt16LE($value, ?int $offset = null): self + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::v; + $this->checkForOverSize(0xffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + public function writeInt32BE($value, ?int $offset = null): self + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::N; + $this->checkForOverSize(0xffffffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + public function writeInt32LE($value, ?int $offset = null): self + { + if (null === $offset) { + $offset = $this->getLastEmptyPosition(); + } + + $format = FormatPackEnum::V; + $this->checkForOverSize(0xffffffff, $value); + $this->insert($format, $value, $offset, $format->getLength()); + + return $this; + } + + public function read(int $offset, int $length) + { + return $this->extract('a' . $length, $offset, $length); + } + + public function readInt8(int $offset) + { + $format = FormatPackEnum::C; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt16BE(int $offset) + { + $format = FormatPackEnum::n; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt16LE(int $offset) + { + $format = FormatPackEnum::v; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt32BE(int $offset) + { + $format = FormatPackEnum::N; + return $this->extract($format, $offset, $format->getLength()); + } + + public function readInt32LE(int $offset) + { + $format = FormatPackEnum::V; + return $this->extract($format, $offset, $format->getLength()); + } +} diff --git a/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php b/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php new file mode 100644 index 000000000..b34c253ea --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php @@ -0,0 +1,180 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers\ByteBuffer; + +/** + * @link https://www.php.net/manual/en/function.pack.php + * + * @author alexandre433. + */ +enum FormatPackEnum: string +{ + /** + * NUL-padded string + */ + case a = 'a'; + + /** + * SPACE-padded string + */ + case A = 'A'; + + /** + * Hex string, low nibble first + */ + case h = 'h'; + + /** + * Hex string, high nibble first + */ + case H = 'H'; + + /** + * signed char + */ + case c = 'c'; + + /** + * unsigned char + */ + case C = 'C'; + + /** + * signed short (always 16 bit, machine byte order) + */ + case s = 's'; + + /** + * unsigned short (always 16 bit, machine byte order) + */ + case S = 'S'; + + /** + * unsigned short (always 16 bit, big endian byte order) + */ + case n = 'n'; + + /** + * unsigned short (always 16 bit, little endian byte order) + */ + case v = 'v'; + + /** + * signed integer (machine dependent size and byte order) + */ + case i = 'i'; + + /** + * unsigned integer (machine dependent size and byte order) + */ + case I = 'I'; + + /** + * signed long (always 32 bit, machine byte order) + */ + case l = 'l'; + + /** + * unsigned long (always 32 bit, machine byte order) + */ + case L = 'L'; + + /** + * unsigned long (always 32 bit, big endian byte order) + */ + case N = 'N'; + + /** + * unsigned long (always 32 bit, little endian byte order) + */ + case V = 'V'; + + /** + * signed long long (always 64 bit, machine byte order) + */ + case q = 'q'; + + /** + * unsigned long long (always 64 bit, machine byte order) + */ + case Q = 'Q'; + + /** + * unsigned long long (always 64 bit, big endian byte order) + */ + case J = 'J'; + + /** + * unsigned long long (always 64 bit, little endian byte order) + */ + case P = 'P'; + + /** + * float (machine dependent size and representation) + */ + case f = 'f'; + + /** + * float (machine dependent size, little endian byte order) + */ + case g = 'g'; + + /** + * float (machine dependent size, big endian byte order) + */ + case G = 'G'; + + /** + * double (machine dependent size and representation) + */ + case d = 'd'; + + /** + * double (machine dependent size, little endian byte order) + */ + case e = 'e'; + + /** + * double (machine dependent size, big endian byte order) + */ + case E = 'E'; + + /** + * NUL byte + */ + case x = 'x'; + + /** + * Back up one byte + */ + case X = 'X'; + + /** + * NUL-padded string + */ + case Z = 'Z'; + + /** + * NUL-fill to absolute position + */ + case At = '@'; + + public function getLength(): int + { + return match ($this) { + self::n, self::v => 2, + self::N, self::V => 4, + self::c, self::C => 1, + default => throw new \InvalidArgumentException('Invalid format pack'), + }; + } +} diff --git a/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php b/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php new file mode 100644 index 000000000..1c81b60e3 --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php @@ -0,0 +1,31 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers\ByteBuffer; + +/** + * @author alexandre433. + */ +interface ReadableBuffer +{ + public function read(int $offset, int $length); + + public function readInt8(int $offset); + + public function readInt16BE(int $offset); + + public function readInt16LE(int $offset); + + public function readInt32BE(int $offset); + + public function readInt32LE(int $offset); + +} diff --git a/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php b/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php new file mode 100644 index 000000000..b26ee70fe --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php @@ -0,0 +1,66 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers\ByteBuffer; + +/** + * @author alexandre433. + */ +interface WriteableBuffer +{ + public function write($value, ?int $offset = null): self; + + /** + * Write an int8 to the buffer + * + * @param mixed $value + * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @return self + */ + public function writeInt8($value, ?int $offset = null): self; + + /** + * Write an int16 to the buffer in big-endian format + * + * @param mixed $value + * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @return self + */ + public function writeInt16BE($value, ?int $offset = null): self; + + /** + * Write an int16 to the buffer in little-endian format + * + * @param mixed $value + * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @return self + */ + public function writeInt16LE($value, ?int $offset = null): self; + + /** + * Write an int32 to the buffer in big-endian format + * + * @param mixed $value + * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @return self + */ + public function writeInt32BE($value, ?int $offset = null): self; + + /** + * Write an int32 to the buffer in little-endian format + * + * @param mixed $value + * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @return self + */ + public function writeInt32LE($value, ?int $offset = null): self; + +} diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 2369be9ee..83179da9f 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -13,17 +13,15 @@ namespace Discord\Voice; -use ArrayAccess; +use Discord\Helpers\ByteBuffer\Buffer as BaseBuffer; use Discord\Helpers\FormatPackEnum; -use SplFixedArray; -use TrafficCophp\ByteBuffer\Buffer as BaseBuffer; /** * A Byte Buffer similar to Buffer in NodeJS. * * @since 3.2.0 */ -class Buffer extends BaseBuffer implements ArrayAccess +class Buffer extends BaseBuffer implements \ArrayAccess { public function __construct($argument) { @@ -50,7 +48,7 @@ public static function make($argument): static protected function initializeStructs($length, $content): void { - $this->buffer = new SplFixedArray($length); + $this->buffer = new \SplFixedArray($length); for ($i = 0; $i < $length; $i++) { $this->buffer[$i] = $content[$i]; } @@ -452,7 +450,7 @@ public function writeRawString(string $value, int $offset): void } /** - * Gets an attribute via key. Used for ArrayAccess. + * Gets an attribute via key. Used for \ArrayAccess. * * @param mixed $key The attribute key. * @@ -465,7 +463,7 @@ public function offsetGet($key) } /** - * Checks if an attribute exists via key. Used for ArrayAccess. + * Checks if an attribute exists via key. Used for \ArrayAccess. * * @param mixed $key The attribute key. * @@ -477,7 +475,7 @@ public function offsetExists($key): bool } /** - * Sets an attribute via key. Used for ArrayAccess. + * Sets an attribute via key. Used for \ArrayAccess. * * @param mixed $key The attribute key. * @param mixed $value The attribute value. @@ -488,7 +486,7 @@ public function offsetSet($key, $value): void } /** - * Unsets an attribute via key. Used for ArrayAccess. + * Unsets an attribute via key. Used for \ArrayAccess. * * @param string $key The attribute key. */ From 153664f66bb5b1a65103bbf23ab246901c3b0959 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:37:58 -0400 Subject: [PATCH 049/121] Remove trafficcophp/bytebuffer as a dependency --- composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/composer.json b/composer.json index a1a9add24..ab7a52124 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,6 @@ "ratchet/pawl": "^0.4.3", "react/datagram": "^1.8", "symfony/options-resolver": "^5.1.11 || ^6.0 || ^7.0", - "trafficcophp/bytebuffer": "^0.3", "monolog/monolog": "^2.1.1 || ^3.0", "react/event-loop": "^1.2", "ext-zlib": "*", From e46f5d315fc7ba3de7b6af0c9d04bc7519985d05 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:40:57 -0400 Subject: [PATCH 050/121] Author formatting --- src/Discord/Helpers/ByteBuffer/AbstractBuffer.php | 2 +- src/Discord/Helpers/ByteBuffer/FormatPackEnum.php | 2 +- src/Discord/Helpers/ByteBuffer/ReadableBuffer.php | 2 +- src/Discord/Helpers/ByteBuffer/WriteableBuffer.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php index 68b7121eb..d556b27ba 100644 --- a/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php +++ b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php @@ -12,7 +12,7 @@ namespace Discord\Helpers\ByteBuffer; /** - * @author alexandre433. + * @author alexandre433 */ abstract class AbstractBuffer implements ReadableBuffer, WriteableBuffer { diff --git a/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php b/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php index b34c253ea..3e2c72ef5 100644 --- a/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php +++ b/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php @@ -14,7 +14,7 @@ /** * @link https://www.php.net/manual/en/function.pack.php * - * @author alexandre433. + * @author alexandre433 */ enum FormatPackEnum: string { diff --git a/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php b/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php index 1c81b60e3..f0b3c3101 100644 --- a/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php +++ b/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php @@ -12,7 +12,7 @@ namespace Discord\Helpers\ByteBuffer; /** - * @author alexandre433. + * @author alexandre433 */ interface ReadableBuffer { diff --git a/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php b/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php index b26ee70fe..1074a1f65 100644 --- a/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php +++ b/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php @@ -12,7 +12,7 @@ namespace Discord\Helpers\ByteBuffer; /** - * @author alexandre433. + * @author alexandre433 */ interface WriteableBuffer { From 60033dd896270204c6d4d178dffda8c963cefd91 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:45:31 -0400 Subject: [PATCH 051/121] Copy iterator into array instead of iterating and concatentating --- src/Discord/Helpers/ByteBuffer/Buffer.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php index 3b2810832..41a399355 100644 --- a/src/Discord/Helpers/ByteBuffer/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -33,11 +33,7 @@ public function __construct($argument) public function __toString(): string { - $buf = ''; - foreach ($this->buffer as $bytes) { - $buf .= $bytes; - } - return $buf; + return implode('', iterator_to_array($this->buffer, false)); } public static function make($argument): static From 5529ded79bff8b81699fbd44104a804da9ab580b Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 05:54:08 -0400 Subject: [PATCH 052/121] excpectedMax => expectedMax --- src/Discord/Helpers/ByteBuffer/Buffer.php | 6 +++--- src/Discord/Voice/Buffer.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php index 41a399355..e523117d2 100644 --- a/src/Discord/Helpers/ByteBuffer/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -84,10 +84,10 @@ protected function extract(FormatPackEnum|string $format, int $offset, int $leng return $result; } - protected function checkForOverSize($excpectedMax, string|int $actual): self + protected function checkForOverSize($expectedMax, string|int $actual): self { - if ($actual > $excpectedMax) { - throw new \InvalidArgumentException(sprintf('%d exceeded limit of %d', $actual, $excpectedMax)); + if ($actual > $expectedMax) { + throw new \InvalidArgumentException("actual exceeded expectedMax limit"); } return $this; diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 83179da9f..71e481d02 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -114,10 +114,10 @@ protected function extract($format, $offset, $length) * @throws \InvalidArgumentException * @return static */ - protected function checkForOverSize($excpectedMax, $actual): self + protected function checkForOverSize($expectedMax, string|int $actual): self { - if ($actual > $excpectedMax) { - throw new \InvalidArgumentException(sprintf('%d exceeded limit of %d', $actual, $excpectedMax)); + if ($actual > $expectedMax) { + throw new \InvalidArgumentException("actual exceeded expectedMax limit"); } return $this; From 6bef84aebc7ef54b86449f8bab57fe36c6b3646a Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:20:36 -0400 Subject: [PATCH 053/121] PHPDocs and params types --- src/Discord/Helpers/ByteBuffer/Buffer.php | 81 ++++++++++++++++++++++- 1 file changed, 79 insertions(+), 2 deletions(-) diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php index e523117d2..3b647169e 100644 --- a/src/Discord/Helpers/ByteBuffer/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -18,8 +18,10 @@ * * @throws \InvalidArgumentException If invalid arguments are provided or buffer overflows. */ -class Buffer extends AbstractBuffer +class Buffer extends AbstractBuffer implements \ArrayAccess { + use BufferArrayAccessTrait; + protected \SplFixedArray $buffer; public function __construct($argument) @@ -49,7 +51,16 @@ protected function initializeStructs(string $length, string $content): void } } - protected function insert(FormatPackEnum|string $format, $value, int $offset, ?int $length): self + /** + * Inserts a value into the buffer at the specified offset. + * + * @param FormatPackEnum|string $format + * @param mixed $value + * @param int $offset + * @param ?int $length + * @return Buffer + */ + protected function insert(FormatPackEnum|string $format, $value, int $offset, ?int $length = null): self { $bytes = pack($format?->value ?? $format, $value); @@ -64,6 +75,14 @@ protected function insert(FormatPackEnum|string $format, $value, int $offset, ?i return $this; } + /** + * Extracts a value from the buffer at the specified offset. + * + * @param FormatPackEnum|string $format + * @param int $offset + * @param int $length + * @return mixed + */ protected function extract(FormatPackEnum|string $format, int $offset, int $length) { $encoded = ''; @@ -84,6 +103,14 @@ protected function extract(FormatPackEnum|string $format, int $offset, int $leng return $result; } + /** + * Checks if the actual value exceeds the expected maximum size. + * + * @param mixed $excpectedMax + * @param mixed $actual + * @throws \InvalidArgumentException + * @return static + */ protected function checkForOverSize($expectedMax, string|int $actual): self { if ($actual > $expectedMax) { @@ -109,6 +136,13 @@ public function getLastEmptyPosition(): int return 0; } + /** + * Writes a string to the buffer at the specified offset. + * + * @param string $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ public function write($value, ?int $offset = null): self { if (null === $offset) { @@ -121,6 +155,13 @@ public function write($value, ?int $offset = null): self return $this; } + /** + * Writes an 8-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ public function writeInt8($value, ?int $offset = null): self { if (null === $offset) { @@ -134,6 +175,13 @@ public function writeInt8($value, ?int $offset = null): self return $this; } + /** + * Writes a 16-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ public function writeInt16BE($value, ?int $offset = null): self { if (null === $offset) { @@ -147,6 +195,13 @@ public function writeInt16BE($value, ?int $offset = null): self return $this; } + /** + * Writes a 16-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ public function writeInt16LE($value, ?int $offset = null): self { if (null === $offset) { @@ -160,6 +215,13 @@ public function writeInt16LE($value, ?int $offset = null): self return $this; } + /** + * Writes a 32-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ public function writeInt32BE($value, ?int $offset = null): self { if (null === $offset) { @@ -173,6 +235,14 @@ public function writeInt32BE($value, ?int $offset = null): self return $this; } + /** + * Writes a 32-bit signed integer to the buffer at the specified offset. + * + * @param int $value The value that will be written. + * @param int|null $offset The offset that the value will be written at. + * @return static + */ + #[\Override] public function writeInt32LE($value, ?int $offset = null): self { if (null === $offset) { @@ -186,6 +256,13 @@ public function writeInt32LE($value, ?int $offset = null): self return $this; } + /** + * Reads a string from the buffer at the specified offset. + * + * @param int $offset The offset to read from. + * @param int $length The length of the string to read. + * @return string The data read. + */ public function read(int $offset, int $length) { return $this->extract('a' . $length, $offset, $length); From 8d444092826dc84a909a75ace5007b2372d5dcba Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:21:08 -0400 Subject: [PATCH 054/121] Reduce ops 37 => 34 --- src/Discord/Helpers/ByteBuffer/Buffer.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php index 3b647169e..be228f806 100644 --- a/src/Discord/Helpers/ByteBuffer/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -26,11 +26,11 @@ class Buffer extends AbstractBuffer implements \ArrayAccess public function __construct($argument) { - match (true) { - is_string($argument) => $this->initializeStructs(strlen($argument), $argument), - is_int($argument) => $this->initializeStructs($argument, pack(FormatPackEnum::x->value . "$argument")), - default => throw new \InvalidArgumentException('Constructor argument must be an binary string or integer') - }; + is_string($argument) + ? $this->initializeStructs(strlen($argument), $argument) + : (is_int($argument) + ? $this->initializeStructs($argument, pack(FormatPackEnum::x->value . "$argument")) + : throw new \InvalidArgumentException('Constructor argument must be an binary string or integer')); } public function __toString(): string From 7459fa6a4a1a46c2522cc5554db0dee8dbf43a87 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:21:24 -0400 Subject: [PATCH 055/121] Update Buffer.php --- src/Discord/Voice/Buffer.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index 71e481d02..b529ccd4d 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -60,10 +60,10 @@ protected function initializeStructs($length, $content): void * @param FormatPackEnum|string $format * @param mixed $value * @param int $offset - * @param mixed $length + * @param ?int $length * @return Buffer */ - protected function insert($format, $value, $offset, $length): self + protected function insert(FormatPackEnum|string $format, $value, $offset, $length): self { $bytes = pack($format?->value ?? $format, $value); From 4af245431ce211e1efde7dcbd3f40683d0c5ab5c Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:21:26 -0400 Subject: [PATCH 056/121] Create BufferArrayAccessTrait.php --- .../ByteBuffer/BufferArrayAccessTrait.php | 214 ++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php diff --git a/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php b/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php new file mode 100644 index 000000000..598b29cfa --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php @@ -0,0 +1,214 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers\ByteBuffer; + +/** + * @author Valithor Obsidion + */ +trait BufferArrayAccessTrait +{ + /** + * Writes a 32-bit unsigned integer with big endian. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeUInt32BE(int $value, int $offset): self + { + return $this->insert(FormatPackEnum::I, $value, $offset, 3); + } + + /** + * Writes a 64-bit unsigned integer with little endian. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeUInt64LE(int $value, int $offset): self + { + return $this->insert(FormatPackEnum::P, $value, $offset, 8); + } + + /** + * Writes a signed integer. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeInt(int $value, int $offset): self + { + return $this->insert(FormatPackEnum::N, $value, $offset, 4); + } + + /** + * Writes a unsigned integer. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeUInt(int $value, int $offset): self + { + return $this->insert(FormatPackEnum::I, $value, $offset, 4); + } + + /** + * Reads a signed integer. + * + * @param int $offset The offset to read from. + * + * @return int The data read. + */ + public function readInt(int $offset): int + { + return $this->extract(FormatPackEnum::N, $offset, 4); + } + + /** + * Reads a signed integer. + * + * @param int $offset The offset to read from. + * + * @return int The data read. + */ + public function readUInt(int $offset): int + { + return $this->extract(FormatPackEnum::I, $offset, 4); + } + + /** + * Writes an unsigned big endian short. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeShort(int $value, int $offset): self + { + return $this->insert(FormatPackEnum::n, $value, $offset, 2); + } + + /** + * Reads an unsigned big endian short. + * + * @param int $offset The offset to read from. + * + * @return int The data read. + */ + public function readShort(int $offset): int + { + return $this->extract(FormatPackEnum::n, $offset, 4); + } + + /** + * Reads a unsigned integer with little endian. + * + * @param int $offset The offset that will be read. + * + * @return int The value that is at the specified offset. + */ + public function readUIntLE(int $offset): int + { + return $this->extract(FormatPackEnum::I, $offset, 3); + } + + public function readChar(int $offset): string + { + return $this->extract(FormatPackEnum::c, $offset, 1); + } + + public function readUChar(int $offset): string + { + return $this->extract(FormatPackEnum::C, $offset, 1); + } + + /** + * Writes a char. + * + * @param string $value The value that will be written. + * @param int $offset The offset that the value will be written. + */ + public function writeChar(string $value, int $offset): self + { + return $this->insert(FormatPackEnum::c, $value, $offset, FormatPackEnum::c->getLength()); + } + + /** + * Writes raw binary to the buffer. + * + * @param int $value The value that will be written. + * @param int $offset The offset that the value will be written at. + */ + public function writeRaw(int $value, int $offset): void + { + $this->buffer[$offset] = $value; + } + + /** + * Writes a binary string to the buffer. + * + * @param string $value The value that will be written. + * @param int $offset The offset that the value will be written at. + */ + public function writeRawString(string $value, int $offset): void + { + for ($i = 0; $i < strlen($value); ++$i) { + $this->buffer[$offset++] = $value[$i]; + } + } + + /** + * Gets an attribute via key. Used for \ArrayAccess. + * + * @param mixed $key The attribute key. + * + * @return mixed + */ + #[\ReturnTypeWillChange] + public function offsetGet($key) + { + return $this->buffer[$key] ?? null; + } + + /** + * Checks if an attribute exists via key. Used for \ArrayAccess. + * + * @param mixed $key The attribute key. + * + * @return bool Whether the offset exists. + */ + public function offsetExists($key): bool + { + return isset($this->buffer[$key]); + } + + /** + * Sets an attribute via key. Used for \ArrayAccess. + * + * @param mixed $key The attribute key. + * @param mixed $value The attribute value. + */ + public function offsetSet($key, $value): void + { + $this->buffer[$key] = $value; + } + + /** + * Unsets an attribute via key. Used for \ArrayAccess. + * + * @param string $key The attribute key. + */ + public function offsetUnset($key): void + { + if (isset($this->buffer[$key])) { + unset($this->buffer[$key]); + } + } +} From 8162df5410fc1e0bd07fa7957aa5bcb555c0a232 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:23:00 -0400 Subject: [PATCH 057/121] insert params --- src/Discord/Voice/Buffer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index b529ccd4d..ec384f117 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -63,7 +63,7 @@ protected function initializeStructs($length, $content): void * @param ?int $length * @return Buffer */ - protected function insert(FormatPackEnum|string $format, $value, $offset, $length): self + protected function insert(FormatPackEnum|string $format, $value, int $offset, ?int $length = null): self { $bytes = pack($format?->value ?? $format, $value); From d17e93cfe673926014c3d7ec94a9b777714867ff Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:25:45 -0400 Subject: [PATCH 058/121] Remove typing for insert's $format param --- src/Discord/Helpers/ByteBuffer/Buffer.php | 2 +- src/Discord/Voice/Buffer.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php index be228f806..9210daf79 100644 --- a/src/Discord/Helpers/ByteBuffer/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -60,7 +60,7 @@ protected function initializeStructs(string $length, string $content): void * @param ?int $length * @return Buffer */ - protected function insert(FormatPackEnum|string $format, $value, int $offset, ?int $length = null): self + protected function insert($format, $value, int $offset, ?int $length = null): self { $bytes = pack($format?->value ?? $format, $value); diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php index ec384f117..5607fdb53 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Voice/Buffer.php @@ -63,7 +63,7 @@ protected function initializeStructs($length, $content): void * @param ?int $length * @return Buffer */ - protected function insert(FormatPackEnum|string $format, $value, int $offset, ?int $length = null): self + protected function insert($format, $value, int $offset, ?int $length = null): self { $bytes = pack($format?->value ?? $format, $value); From e2cc7a6d08b54f1345f51311de6cca2a8292754c Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:27:29 -0400 Subject: [PATCH 059/121] Use existing FormatPackEnum class --- src/Discord/Helpers/ByteBuffer/Buffer.php | 2 + .../ByteBuffer/BufferArrayAccessTrait.php | 2 + .../Helpers/ByteBuffer/FormatPackEnum.php | 180 ------------------ 3 files changed, 4 insertions(+), 180 deletions(-) delete mode 100644 src/Discord/Helpers/ByteBuffer/FormatPackEnum.php diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php index 9210daf79..d5172d957 100644 --- a/src/Discord/Helpers/ByteBuffer/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -11,6 +11,8 @@ namespace Discord\Helpers\ByteBuffer; +use Discord\Helpers\FormatPackEnum; + /** * Helper class for handling binary data. * diff --git a/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php b/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php index 598b29cfa..7f43a9d93 100644 --- a/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php +++ b/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php @@ -11,6 +11,8 @@ namespace Discord\Helpers\ByteBuffer; +use Discord\Helpers\FormatPackEnum; + /** * @author Valithor Obsidion */ diff --git a/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php b/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php deleted file mode 100644 index 3e2c72ef5..000000000 --- a/src/Discord/Helpers/ByteBuffer/FormatPackEnum.php +++ /dev/null @@ -1,180 +0,0 @@ - - * - * This file is subject to the MIT license that is bundled - * with this source code in the LICENSE.md file. - */ - -namespace Discord\Helpers\ByteBuffer; - -/** - * @link https://www.php.net/manual/en/function.pack.php - * - * @author alexandre433 - */ -enum FormatPackEnum: string -{ - /** - * NUL-padded string - */ - case a = 'a'; - - /** - * SPACE-padded string - */ - case A = 'A'; - - /** - * Hex string, low nibble first - */ - case h = 'h'; - - /** - * Hex string, high nibble first - */ - case H = 'H'; - - /** - * signed char - */ - case c = 'c'; - - /** - * unsigned char - */ - case C = 'C'; - - /** - * signed short (always 16 bit, machine byte order) - */ - case s = 's'; - - /** - * unsigned short (always 16 bit, machine byte order) - */ - case S = 'S'; - - /** - * unsigned short (always 16 bit, big endian byte order) - */ - case n = 'n'; - - /** - * unsigned short (always 16 bit, little endian byte order) - */ - case v = 'v'; - - /** - * signed integer (machine dependent size and byte order) - */ - case i = 'i'; - - /** - * unsigned integer (machine dependent size and byte order) - */ - case I = 'I'; - - /** - * signed long (always 32 bit, machine byte order) - */ - case l = 'l'; - - /** - * unsigned long (always 32 bit, machine byte order) - */ - case L = 'L'; - - /** - * unsigned long (always 32 bit, big endian byte order) - */ - case N = 'N'; - - /** - * unsigned long (always 32 bit, little endian byte order) - */ - case V = 'V'; - - /** - * signed long long (always 64 bit, machine byte order) - */ - case q = 'q'; - - /** - * unsigned long long (always 64 bit, machine byte order) - */ - case Q = 'Q'; - - /** - * unsigned long long (always 64 bit, big endian byte order) - */ - case J = 'J'; - - /** - * unsigned long long (always 64 bit, little endian byte order) - */ - case P = 'P'; - - /** - * float (machine dependent size and representation) - */ - case f = 'f'; - - /** - * float (machine dependent size, little endian byte order) - */ - case g = 'g'; - - /** - * float (machine dependent size, big endian byte order) - */ - case G = 'G'; - - /** - * double (machine dependent size and representation) - */ - case d = 'd'; - - /** - * double (machine dependent size, little endian byte order) - */ - case e = 'e'; - - /** - * double (machine dependent size, big endian byte order) - */ - case E = 'E'; - - /** - * NUL byte - */ - case x = 'x'; - - /** - * Back up one byte - */ - case X = 'X'; - - /** - * NUL-padded string - */ - case Z = 'Z'; - - /** - * NUL-fill to absolute position - */ - case At = '@'; - - public function getLength(): int - { - return match ($this) { - self::n, self::v => 2, - self::N, self::V => 4, - self::c, self::C => 1, - default => throw new \InvalidArgumentException('Invalid format pack'), - }; - } -} From e5729afc907c952c594589a2664029b390ea3a8f Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:29:45 -0400 Subject: [PATCH 060/121] Updating typings --- src/Discord/Voice/VoiceClient.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 199837afc..73a317cd8 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -308,7 +308,7 @@ class VoiceClient extends EventEmitter * * @var string|\React\Dns\Config\Config */ - protected null|string|Config $dnsConfig; + protected Config|string|null $dnsConfig; /** * Silence Frame Remain Count. @@ -1428,9 +1428,9 @@ public function handleVoiceStateUpdate(object $data): void * * @deprecated 10.5.0 Use getReceiveStream instead. * - * @return null|RecieveStream|ReceiveStream + * @return RecieveStream|ReceiveStream|null */ - public function getRecieveStream($id): null|RecieveStream|ReceiveStream + public function getRecieveStream($id) { return $this->getReceiveStream($id); } @@ -1440,9 +1440,9 @@ public function getRecieveStream($id): null|RecieveStream|ReceiveStream * * @param int|string $id Either a SSRC or User ID. * - * @return null|ReceiveStream + * @return ReceiveStream|null */ - public function getReceiveStream($id): null|ReceiveStream + public function getReceiveStream($id) { if (isset($this->receiveStreams[$id])) { return $this->receiveStreams[$id]; From 31205db8b5513f8e22136ca0167402412f530b94 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:36:19 -0400 Subject: [PATCH 061/121] Update types --- src/Discord/Voice/VoiceClient.php | 105 ++++++++++++++++-------------- src/Discord/Voice/VoicePacket.php | 42 ++++++------ 2 files changed, 76 insertions(+), 71 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 73a317cd8..33f578a8b 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -70,56 +70,56 @@ class VoiceClient extends EventEmitter * * @var bool Whether the voice client is ready. */ - protected bool $ready = false; + protected $ready = false; /** * The DCA binary name that we will use. * - * @var string The DCA binary name that will be run. + * @var string|null The DCA binary name that will be run. */ - protected ?string $dca; + protected $dca; /** * The FFmpeg binary location. * - * @var string + * @var string|null The FFmpeg binary location. */ - protected ?string $ffmpeg; + protected $ffmpeg; /** * The voice WebSocket instance. * - * @var WebSocket The voice WebSocket client. + * @var WebSocket|null The voice WebSocket client. */ - protected ?WebSocket $voiceWebsocket; + protected $voiceWebsocket; /** * The UDP client. * - * @var Socket The voiceUDP client. + * @var Socket|null The voiceUDP client. */ - public ?Socket $client; + public $client; /** * The Voice WebSocket endpoint. * - * @var string The endpoint the Voice WebSocket and UDP client will connect to. + * @var string|null The endpoint the Voice WebSocket and UDP client will connect to. */ - protected ?string $endpoint; + protected $endpoint; /** * The port the UDP client will use. * - * @var int The port that the UDP client will connect to. + * @var int|null The port that the UDP client will connect to. */ - protected ?int $udpPort; + protected $udpPort; /** * The UDP heartbeat interval. * - * @var int How often we send a heartbeat packet. + * @var int|null How often we send a heartbeat packet. */ - protected ?int $heartbeatInterval; + protected $heartbeatInterval; /** * The Voice WebSocket heartbeat timer. @@ -140,28 +140,28 @@ class VoiceClient extends EventEmitter * * @var int The heartbeat sequence. */ - protected int $heartbeatSeq = 0; + protected $heartbeatSeq = 0; /** * The SSRC value. * - * @var int The SSRC value used for RTP. + * @var int|null The SSRC value used for RTP. */ - public ?int $ssrc; + public $ssrc; /** * The sequence of audio packets being sent. * * @var int The sequence of audio packets. */ - protected int $seq = 0; + protected $seq = 0; /** * The timestamp of the last packet. * * @var int The timestamp the last packet was constructed. */ - protected int $timestamp = 0; + protected $timestamp = 0; /** * The Voice WebSocket mode. @@ -169,63 +169,63 @@ class VoiceClient extends EventEmitter * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes * @var string The voice mode. */ - protected string $mode = 'aead_aes256_gcm_rtpsize'; + protected $mode = 'aead_aes256_gcm_rtpsize'; /** * The secret key used for encrypting voice. * - * @var string The secret key. + * @var string|null The secret key. */ - protected ?string $secretKey; + protected $secretKey; /** - * The raw secret key + * The raw secret key. * - * @var array + * @var array|null The raw secret key. */ - protected ?array $rawKey; + protected $rawKey; /** * Are we currently set as speaking? * * @var bool Whether we are speaking or not. */ - protected bool $speaking = false; + protected $speaking = false; /** * Whether the voice client is currently paused. * * @var bool Whether the voice client is currently paused. */ - protected bool $paused = false; + protected $paused = false; /** * Have we sent the login frame yet? * * @var bool Whether we have sent the login frame. */ - protected bool $sentLoginFrame = false; + protected $sentLoginFrame = false; /** * The time we started sending packets. * - * @var float The time we started sending packets. + * @var float|int|null The time we started sending packets. */ - protected float|int|null $startTime; + protected $startTime; /** * The stream time of the last packet. * * @var int The time we sent the last packet. */ - protected int $streamTime = 0; + protected $streamTime = 0; /** * The size of audio frames, in milliseconds. * * @var int The size of audio frames. */ - protected int $frameSize = 20; + protected $frameSize = 20; /** * Collection of the status of people speaking. @@ -246,23 +246,23 @@ class VoiceClient extends EventEmitter * * @deprecated 10.5.0 Use receiveStreams instead. * - * @var array Voice audio recieve streams. + * @var array|null Voice audio recieve streams. */ - protected ?array $recieveStreams; + protected $recieveStreams; /** * Voice audio recieve streams. * - * @var array Voice audio recieve streams. + * @var array|null Voice audio recieve streams. */ - protected ?array $receiveStreams; + protected $receiveStreams; /** * The volume the audio will be encoded with. * * @var int The volume that the audio will be encoded in. */ - protected int $volume = 100; + protected $volume = 100; /** * The audio application to encode with. @@ -271,28 +271,28 @@ class VoiceClient extends EventEmitter * * @var string The audio application. */ - protected string $audioApplication = 'audio'; + protected $audioApplication = 'audio'; /** * The bitrate to encode with. * * @var int Encoding bitrate. */ - protected int $bitrate = 128000; + protected $bitrate = 128000; /** * Is the voice client reconnecting? * * @var bool Whether the voice client is reconnecting. */ - protected bool $reconnecting = false; + protected $reconnecting = false; /** * Is the voice client being closed by user? * * @var bool Whether the voice client is being closed by user. */ - protected bool $userClose = false; + protected $userClose = false; /** * The Discord voice gateway version. @@ -301,21 +301,21 @@ class VoiceClient extends EventEmitter * * @var int Voice version. */ - protected int $version = 8; + protected $version = 8; /** * The Config for DNS Resolver. * - * @var string|\React\Dns\Config\Config + * @var \React\Dns\Config\Config|string|null */ - protected Config|string|null $dnsConfig; + protected $dnsConfig; /** * Silence Frame Remain Count. * * @var int Amount of silence frames remaining. */ - protected int $silenceRemaining = 5; + protected $silenceRemaining = 5; /** * readopus Timer. @@ -327,18 +327,23 @@ class VoiceClient extends EventEmitter /** * Audio Buffer. * - * @var RealBuffer The Audio Buffer + * @var RealBuffer|null The Audio Buffer */ - protected ?RealBuffer $buffer; + protected $buffer; /** * Current clients connected to the voice chat * * @var array */ - public array $clientsConnected = []; + public $clientsConnected = []; - public array $tempFiles; + /** + * Temporary files. + * + * @var array|null + */ + public $tempFiles; /** * Constructs the Voice client instance diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index 37f7129e9..9dad0a2a7 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -56,7 +56,7 @@ class VoicePacket * * @var string */ - protected string $header; + protected $header; /** * The buffer containing the voice packet. @@ -70,30 +70,30 @@ class VoicePacket /** * The client SSRC. * - * @var int The client SSRC. + * @var int|null The client SSRC. */ - public ?int $ssrc; + public $ssrc; /** * The packet sequence. * - * @var int The packet sequence. + * @var int|null The packet sequence. */ - public ?int $seq; + public $seq; /** * The packet timestamp. * - * @var int The packet timestamp. + * @var int|null The packet timestamp. */ - public ?int $timestamp; + public $timestamp; /** * The version and flags. * - * @var string The version and flags. + * @var string|null The version and flags. */ - public ?string $versionPlusFlags; + public $versionPlusFlags; /** * The payload type. @@ -105,37 +105,37 @@ class VoicePacket /** * The encrypted audio. * - * @var string The encrypted audio. + * @var string|null The encrypted audio. */ - public ?string $encryptedAudio; + public $encryptedAudio; /** * The dencrypted audio. * - * @var string + * @var string|false|null */ - public null|string|false $decryptedAudio; + public $decryptedAudio; /** * The secret key. * - * @var string The secret key. + * @var string|null The secret key. */ - public ?string $secretKey; + public $secretKey; /** * The raw data * * @var string */ - private string $rawData; + private $rawData; /** * Current packet header size. May differ depending on the RTP header. * * @var int */ - private int $headerSize; + private $headerSize; /** * Constructs the voice packet. @@ -207,9 +207,9 @@ public function unpack(string $message): self * * @param string|null $message The message to decrypt. * - * @return false|null|string + * @return string|false|null */ - public function decrypt(?string $message = null): false|null|string + public function decrypt(?string $message = null): string|false|null { if (! $message) { $message = $this?->rawData ?? null; @@ -443,9 +443,9 @@ public function __toString(): string * Retrieves the decrypted audio data. * Will return null if the audio data is not decrypted and false on error. * - * @return null|string|false + * @return string|false|null */ - public function getAudioData(): null|string|false + public function getAudioData(): string|false|null { return $this?->decryptedAudio; } From e1484bf6b13ab7bef8934a7d14e3381563be971f Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:37:51 -0400 Subject: [PATCH 062/121] Update Buffer.php --- src/Discord/Helpers/ByteBuffer/Buffer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Helpers/ByteBuffer/Buffer.php b/src/Discord/Helpers/ByteBuffer/Buffer.php index d5172d957..965e80dd7 100644 --- a/src/Discord/Helpers/ByteBuffer/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -45,7 +45,7 @@ public static function make($argument): static return new static($argument); } - protected function initializeStructs(string $length, string $content): void + protected function initializeStructs($length, string $content): void { $this->buffer = new \SplFixedArray($length); for ($i = 0; $i < $length; $i++) { From 648c6ea5bde1d1cecd3b7b8396308b105d544b0e Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:39:28 -0400 Subject: [PATCH 063/121] use Discord\Helpers\ByteBuffer\Buffer --- src/Discord/Voice/Buffer.php | 499 ------------------------------ src/Discord/Voice/VoiceClient.php | 1 + src/Discord/Voice/VoicePacket.php | 1 + 3 files changed, 2 insertions(+), 499 deletions(-) delete mode 100644 src/Discord/Voice/Buffer.php diff --git a/src/Discord/Voice/Buffer.php b/src/Discord/Voice/Buffer.php deleted file mode 100644 index 5607fdb53..000000000 --- a/src/Discord/Voice/Buffer.php +++ /dev/null @@ -1,499 +0,0 @@ - - * - * This file is subject to the MIT license that is bundled - * with this source code in the LICENSE.md file. - */ - -namespace Discord\Voice; - -use Discord\Helpers\ByteBuffer\Buffer as BaseBuffer; -use Discord\Helpers\FormatPackEnum; - -/** - * A Byte Buffer similar to Buffer in NodeJS. - * - * @since 3.2.0 - */ -class Buffer extends BaseBuffer implements \ArrayAccess -{ - public function __construct($argument) - { - match (true) { - is_string($argument) => $this->initializeStructs(strlen($argument), $argument), - is_int($argument) => $this->initializeStructs($argument, pack(FormatPackEnum::x->value . "$argument")), - default => throw new \InvalidArgumentException('Constructor argument must be an binary string or integer') - }; - } - - public function __toString(): string - { - $buf = ''; - foreach ($this->buffer as $bytes) { - $buf .= $bytes; - } - return $buf; - } - - public static function make($argument): static - { - return new static($argument); - } - - protected function initializeStructs($length, $content): void - { - $this->buffer = new \SplFixedArray($length); - for ($i = 0; $i < $length; $i++) { - $this->buffer[$i] = $content[$i]; - } - } - - /** - * Inserts a value into the buffer at the specified offset. - * - * @param FormatPackEnum|string $format - * @param mixed $value - * @param int $offset - * @param ?int $length - * @return Buffer - */ - protected function insert($format, $value, int $offset, ?int $length = null): self - { - $bytes = pack($format?->value ?? $format, $value); - - if (null === $length) { - $length = strlen($bytes); - } - - for ($i = 0; $i < strlen($bytes); $i++) { - $this->buffer[$offset++] = $bytes[$i]; - } - - return $this; - } - - /** - * Extracts a value from the buffer at the specified offset. - * - * @param FormatPackEnum|string $format - * @param int $offset - * @param int $length - * @return mixed - */ - protected function extract($format, $offset, $length) - { - $encoded = ''; - for ($i = 0; $i < $length; $i++) { - $encoded .= $this->buffer->offsetGet($offset + $i); - } - - if ($format == FormatPackEnum::N && PHP_INT_SIZE <= 4) { - [, $h, $l] = unpack('n*', $encoded); - $result = $l + $h * 0x010000; - } elseif ($format == FormatPackEnum::V && PHP_INT_SIZE <= 4) { - [, $h, $l] = unpack('v*', $encoded); - $result = $h + $l * 0x010000; - } else { - [, $result] = unpack($format?->value ?? $format, $encoded); - } - - return $result; - } - - /** - * Checks if the actual value exceeds the expected maximum size. - * - * @param mixed $excpectedMax - * @param mixed $actual - * @throws \InvalidArgumentException - * @return static - */ - protected function checkForOverSize($expectedMax, string|int $actual): self - { - if ($actual > $expectedMax) { - throw new \InvalidArgumentException("actual exceeded expectedMax limit"); - } - - return $this; - } - - public function length(): int - { - return $this->buffer->getSize(); - } - - public function getLastEmptyPosition(): int - { - foreach($this->buffer as $key => $value) { - if (empty(trim($value))) { - return $key; - } - } - - return 0; - } - - /** - * Writes a string to the buffer at the specified offset. - * - * @param string $value The value that will be written. - * @param int|null $offset The offset that the value will be written at. - * @return static - */ - public function write($value, $offset = null): self - { - if (null === $offset) { - $offset = $this->getLastEmptyPosition(); - } - - $length = strlen($value); - $this->insert('a' . $length, $value, $offset, $length); - - return $this; - } - - /** - * Writes an 8-bit signed integer to the buffer at the specified offset. - * - * @param int $value The value that will be written. - * @param int|null $offset The offset that the value will be written at. - * @return static - */ - public function writeInt8($value, $offset = null): self - { - if (null === $offset) { - $offset = $this->getLastEmptyPosition(); - } - - $format = FormatPackEnum::C; - $this->checkForOverSize(0xff, $value); - $this->insert($format, $value, $offset, $format->getLength()); - - return $this; - } - - /** - * Writes a 16-bit signed integer to the buffer at the specified offset. - * - * @param int $value The value that will be written. - * @param int|null $offset The offset that the value will be written at. - * @return static - */ - public function writeInt16BE($value, $offset = null): self - { - if (null === $offset) { - $offset = $this->getLastEmptyPosition(); - } - - $format = FormatPackEnum::n; - $this->checkForOverSize(0xffff, $value); - $this->insert($format, $value, $offset, $format->getLength()); - - return $this; - } - - /** - * Writes a 16-bit signed integer to the buffer at the specified offset. - * - * @param int $value The value that will be written. - * @param int|null $offset The offset that the value will be written at. - * @return static - */ - public function writeInt16LE($value, $offset = null): self - { - if (null === $offset) { - $offset = $this->getLastEmptyPosition(); - } - - $format = FormatPackEnum::v; - $this->checkForOverSize(0xffff, $value); - $this->insert($format, $value, $offset, $format->getLength()); - - return $this; - } - - /** - * Writes a 32-bit signed integer to the buffer at the specified offset. - * - * @param int $value The value that will be written. - * @param int|null $offset The offset that the value will be written at. - * @return static - */ - public function writeInt32BE($value, $offset = null): self - { - if (null === $offset) { - $offset = $this->getLastEmptyPosition(); - } - - $format = FormatPackEnum::N; - $this->checkForOverSize(0xffffffff, $value); - $this->insert($format, $value, $offset, $format->getLength()); - - return $this; - } - - /** - * Writes a 32-bit signed integer to the buffer at the specified offset. - * - * @param int $value The value that will be written. - * @param int|null $offset The offset that the value will be written at. - * @return static - */ - #[\Override] - public function writeInt32LE($value, $offset = null): self - { - if (null === $offset) { - $offset = $this->getLastEmptyPosition(); - } - - $format = FormatPackEnum::V; - $this->checkForOverSize(0xffffffff, $value); - $this->insert($format, $value, $offset, $format->getLength()); - - return $this; - } - - /** - * Reads a string from the buffer at the specified offset. - * - * @param int $offset The offset to read from. - * @param int $length The length of the string to read. - * @return string The data read. - */ - public function read($offset, $length) - { - return $this->extract('a' . $length, $offset, $length); - } - - public function readInt8($offset) - { - $format = FormatPackEnum::C; - return $this->extract($format, $offset, $format->getLength()); - } - - public function readInt16BE($offset) - { - $format = FormatPackEnum::n; - return $this->extract($format, $offset, $format->getLength()); - } - - public function readInt16LE($offset) - { - $format = FormatPackEnum::v; - return $this->extract($format, $offset, $format->getLength()); - } - - public function readInt32BE($offset) - { - $format = FormatPackEnum::N; - return $this->extract($format, $offset, $format->getLength()); - } - - public function readInt32LE($offset) - { - $format = FormatPackEnum::V; - return $this->extract($format, $offset, $format->getLength()); - } - - /** - * Writes a 32-bit unsigned integer with big endian. - * - * @param int $value The value that will be written. - * @param int $offset The offset that the value will be written. - */ - public function writeUInt32BE(int $value, int $offset): self - { - return $this->insert(FormatPackEnum::I, $value, $offset, 3); - } - - /** - * Writes a 64-bit unsigned integer with little endian. - * - * @param int $value The value that will be written. - * @param int $offset The offset that the value will be written. - */ - public function writeUInt64LE(int $value, int $offset): self - { - return $this->insert(FormatPackEnum::P, $value, $offset, 8); - } - - /** - * Writes a signed integer. - * - * @param int $value The value that will be written. - * @param int $offset The offset that the value will be written. - */ - public function writeInt(int $value, int $offset): self - { - return $this->insert(FormatPackEnum::N, $value, $offset, 4); - } - - /** - * Writes a unsigned integer. - * - * @param int $value The value that will be written. - * @param int $offset The offset that the value will be written. - */ - public function writeUInt(int $value, int $offset): self - { - return $this->insert(FormatPackEnum::I, $value, $offset, 4); - } - - /** - * Reads a signed integer. - * - * @param int $offset The offset to read from. - * - * @return int The data read. - */ - public function readInt(int $offset): int - { - return $this->extract(FormatPackEnum::N, $offset, 4); - } - - /** - * Reads a signed integer. - * - * @param int $offset The offset to read from. - * - * @return int The data read. - */ - public function readUInt(int $offset): int - { - return $this->extract(FormatPackEnum::I, $offset, 4); - } - - /** - * Writes an unsigned big endian short. - * - * @param int $value The value that will be written. - * @param int $offset The offset that the value will be written. - */ - public function writeShort(int $value, int $offset): self - { - return $this->insert(FormatPackEnum::n, $value, $offset, 2); - } - - /** - * Reads an unsigned big endian short. - * - * @param int $offset The offset to read from. - * - * @return int The data read. - */ - public function readShort(int $offset): int - { - return $this->extract(FormatPackEnum::n, $offset, 4); - } - - /** - * Reads a unsigned integer with little endian. - * - * @param int $offset The offset that will be read. - * - * @return int The value that is at the specified offset. - */ - public function readUIntLE(int $offset): int - { - return $this->extract(FormatPackEnum::I, $offset, 3); - } - - public function readChar(int $offset): string - { - return $this->extract(FormatPackEnum::c, $offset, 1); - } - - public function readUChar(int $offset): string - { - return $this->extract(FormatPackEnum::C, $offset, 1); - } - - /** - * Writes a char. - * - * @param string $value The value that will be written. - * @param int $offset The offset that the value will be written. - */ - public function writeChar(string $value, int $offset): self - { - return $this->insert(FormatPackEnum::c, $value, $offset, FormatPackEnum::c->getLength()); - } - - /** - * Writes raw binary to the buffer. - * - * @param int $value The value that will be written. - * @param int $offset The offset that the value will be written at. - */ - public function writeRaw(int $value, int $offset): void - { - $this->buffer[$offset] = $value; - } - - /** - * Writes a binary string to the buffer. - * - * @param string $value The value that will be written. - * @param int $offset The offset that the value will be written at. - */ - public function writeRawString(string $value, int $offset): void - { - for ($i = 0; $i < strlen($value); ++$i) { - $this->buffer[$offset++] = $value[$i]; - } - } - - /** - * Gets an attribute via key. Used for \ArrayAccess. - * - * @param mixed $key The attribute key. - * - * @return mixed - */ - #[\ReturnTypeWillChange] - public function offsetGet($key) - { - return $this->buffer[$key] ?? null; - } - - /** - * Checks if an attribute exists via key. Used for \ArrayAccess. - * - * @param mixed $key The attribute key. - * - * @return bool Whether the offset exists. - */ - public function offsetExists($key): bool - { - return isset($this->buffer[$key]); - } - - /** - * Sets an attribute via key. Used for \ArrayAccess. - * - * @param mixed $key The attribute key. - * @param mixed $value The attribute value. - */ - public function offsetSet($key, $value): void - { - $this->buffer[$key] = $value; - } - - /** - * Unsets an attribute via key. Used for \ArrayAccess. - * - * @param string $key The attribute key. - */ - public function offsetUnset($key): void - { - if (isset($this->buffer[$key])) { - unset($this->buffer[$key]); - } - } -} diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 33f578a8b..b0d248aae 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -20,6 +20,7 @@ use Discord\Exceptions\Voice\ClientNotReadyException; use Discord\Exceptions\Voice\AudioAlreadyPlayingException; use Discord\Helpers\Buffer as RealBuffer; +use Discord\Helpers\ByteBuffer\Buffer; use Discord\Helpers\Collection; use Discord\Helpers\ExCollectionInterface; use Discord\Parts\Channel\Channel; diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index 9dad0a2a7..24b6d12ff 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -13,6 +13,7 @@ namespace Discord\Voice; +use Discord\Helpers\ByteBuffer\Buffer; use Monolog\Logger; use Discord\Helpers\FormatPackEnum; From 72a5dd7e85469c6b380a897f5995617d833af888 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:41:07 -0400 Subject: [PATCH 064/121] PHPDoc --- src/Discord/Voice/VoiceClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index b0d248aae..d6787570c 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -307,7 +307,7 @@ class VoiceClient extends EventEmitter /** * The Config for DNS Resolver. * - * @var \React\Dns\Config\Config|string|null + * @var Config|string|null */ protected $dnsConfig; From 08708aba02509ca895ad7f460a6b670ca032e1c1 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:41:54 -0400 Subject: [PATCH 065/121] VoicePacket Imports --- src/Discord/Voice/VoicePacket.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index 24b6d12ff..7b371bd16 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -14,8 +14,8 @@ namespace Discord\Voice; use Discord\Helpers\ByteBuffer\Buffer; -use Monolog\Logger; use Discord\Helpers\FormatPackEnum; +use Monolog\Logger; /** * A voice packet received from Discord. From 66495a4e12e96a8a107dd83b8c1f7009c68cc172 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:42:34 -0400 Subject: [PATCH 066/121] Fix VoicePacket property typing --- src/Discord/Voice/VoicePacket.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index 7b371bd16..0ab631e0f 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -99,9 +99,9 @@ class VoicePacket /** * The payload type. * - * @var string The payload type. + * @var string|null The payload type. */ - public ?string $payloadType; + public $payloadType; /** * The encrypted audio. From acbccb1b2be81abd5eea53844248822661cf39cc Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:43:10 -0400 Subject: [PATCH 067/121] declare(strict_types=1); --- src/Discord/Voice/ReceiveStream.php | 2 ++ src/Discord/Voice/Voice.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php index 659b83c05..7cbb4faf8 100644 --- a/src/Discord/Voice/ReceiveStream.php +++ b/src/Discord/Voice/ReceiveStream.php @@ -1,5 +1,7 @@ Date: Sat, 14 Jun 2025 06:43:37 -0400 Subject: [PATCH 068/121] declare(strict_types=1); --- src/Discord/Helpers/ByteBuffer/AbstractBuffer.php | 2 ++ src/Discord/Helpers/ByteBuffer/Buffer.php | 2 ++ src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php | 2 ++ src/Discord/Helpers/ByteBuffer/ReadableBuffer.php | 2 ++ src/Discord/Helpers/ByteBuffer/WriteableBuffer.php | 2 ++ 5 files changed, 10 insertions(+) diff --git a/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php index d556b27ba..cf5e797b3 100644 --- a/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php +++ b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php @@ -1,5 +1,7 @@ Date: Sat, 14 Jun 2025 06:45:21 -0400 Subject: [PATCH 069/121] Type ordering --- src/Discord/Helpers/Buffer.php | 2 +- src/Discord/Helpers/ByteBuffer/WriteableBuffer.php | 10 +++++----- src/Discord/Helpers/CacheConfig.php | 4 ++-- src/Discord/Parts/Channel/Message.php | 2 +- src/Discord/Voice/OpusHead.php | 4 ++-- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Discord/Helpers/Buffer.php b/src/Discord/Helpers/Buffer.php index f78990603..ed7ac6cfd 100644 --- a/src/Discord/Helpers/Buffer.php +++ b/src/Discord/Helpers/Buffer.php @@ -106,7 +106,7 @@ private function readRaw(int $length) * read. * * @param int $length Number of bytes to read. - * @param null|string $format Format to read the bytes in. See `pack()`. + * @param string|null $format Format to read the bytes in. See `pack()`. * @param int $timeout Time in milliseconds before the read times out. * * @return PromiseInterface diff --git a/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php b/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php index 672eb37b2..e96e4a798 100644 --- a/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php +++ b/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php @@ -24,7 +24,7 @@ public function write($value, ?int $offset = null): self; * Write an int8 to the buffer * * @param mixed $value - * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @param int|null $offset The offset to write the int8, if not provided the length of the buffer will be used * @return self */ public function writeInt8($value, ?int $offset = null): self; @@ -33,7 +33,7 @@ public function writeInt8($value, ?int $offset = null): self; * Write an int16 to the buffer in big-endian format * * @param mixed $value - * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @param int|null $offset The offset to write the int8, if not provided the length of the buffer will be used * @return self */ public function writeInt16BE($value, ?int $offset = null): self; @@ -42,7 +42,7 @@ public function writeInt16BE($value, ?int $offset = null): self; * Write an int16 to the buffer in little-endian format * * @param mixed $value - * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @param int|null $offset The offset to write the int8, if not provided the length of the buffer will be used * @return self */ public function writeInt16LE($value, ?int $offset = null): self; @@ -51,7 +51,7 @@ public function writeInt16LE($value, ?int $offset = null): self; * Write an int32 to the buffer in big-endian format * * @param mixed $value - * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @param int|null $offset The offset to write the int8, if not provided the length of the buffer will be used * @return self */ public function writeInt32BE($value, ?int $offset = null): self; @@ -60,7 +60,7 @@ public function writeInt32BE($value, ?int $offset = null): self; * Write an int32 to the buffer in little-endian format * * @param mixed $value - * @param null|int $offset The offset to write the int8, if not provided the length of the buffer will be used + * @param int|null $offset The offset to write the int8, if not provided the length of the buffer will be used * @return self */ public function writeInt32LE($value, ?int $offset = null): self; diff --git a/src/Discord/Helpers/CacheConfig.php b/src/Discord/Helpers/CacheConfig.php index 8f1bcdbd7..347c35d3e 100644 --- a/src/Discord/Helpers/CacheConfig.php +++ b/src/Discord/Helpers/CacheConfig.php @@ -53,7 +53,7 @@ class CacheConfig /** * The default Time To Live for `$interface::set()` and `$interface::setMultiple()`. * - * @var null|int|\DateInterval|float + * @var \DateInterval|float|int|null */ public $ttl; @@ -62,7 +62,7 @@ class CacheConfig * @param bool $compress Whether to compress cache data before serialization, ignored in ArrayCache. * @param bool $sweep Whether to automatically sweep cache. * @param string|null $separator The cache key prefix separator, null for default. - * @param null|int|\DateInterval|float $ttl The cache time to live default value to pass to the interface. + * @param \DateInterval|float|int|null $ttl The cache time to live default value to pass to the interface. */ public function __construct($interface, bool $compress = false, bool $sweep = false, ?string $separator = null, $ttl = null) { diff --git a/src/Discord/Parts/Channel/Message.php b/src/Discord/Parts/Channel/Message.php index 1806ed0ba..9dec39e8e 100644 --- a/src/Discord/Parts/Channel/Message.php +++ b/src/Discord/Parts/Channel/Message.php @@ -863,7 +863,7 @@ public function getLinkAttribute(): ?string * * @since 10.0.0 Arguments for `$name` and `$auto_archive_duration` are now inside `$options` */ - public function startThread(array|string $options, string|null|int $reason = null, ?string $_reason = null): PromiseInterface + public function startThread(array|string $options, string|int|null $reason = null, ?string $_reason = null): PromiseInterface { // Old v7 signature if (is_string($options)) { diff --git a/src/Discord/Voice/OpusHead.php b/src/Discord/Voice/OpusHead.php index 8b2bcaaae..2fb1cb9e9 100644 --- a/src/Discord/Voice/OpusHead.php +++ b/src/Discord/Voice/OpusHead.php @@ -85,7 +85,7 @@ class OpusHead /** * The total number of streams encoded in each Ogg packet. * - * @var null|int + * @var int|null */ public ?int $streamCount = null; @@ -93,7 +93,7 @@ class OpusHead * The number of streams whose decoders are to be configured to produce two * channels (stereo). * - * @var null|int + * @var int|null */ public ?int $twoChannelStreamCount = null; From b0d13021cd722e3222168e561e3dfec23f0b9914 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 06:48:48 -0400 Subject: [PATCH 070/121] Remove whitespace --- src/Discord/Voice/VoiceClient.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index d6787570c..bc705b883 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -1913,5 +1913,4 @@ public function insertSilence(): void $this->sendBuffer(self::SILENCE_FRAME); } } - } From 8d06d7f3b2059584b9828f909d6a68512badb1e8 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 07:01:13 -0400 Subject: [PATCH 071/121] VoicePayload class --- src/Discord/WebSockets/VoicePayload.php | 73 +++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/Discord/WebSockets/VoicePayload.php diff --git a/src/Discord/WebSockets/VoicePayload.php b/src/Discord/WebSockets/VoicePayload.php new file mode 100644 index 000000000..c7c3f1aff --- /dev/null +++ b/src/Discord/WebSockets/VoicePayload.php @@ -0,0 +1,73 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\WebSockets; + +use JsonSerializable; + +/** + * Represents a Gateway event payload with a voice token. + * + * Gateway event payloads have a common structure, but the contents of the associated data (d) varies between the different events. + * + * @link https://discord.com/developers/docs/topics/voice-connections#retrieving-voice-server-information-example-voice-server-update-payload + * + * @property token + */ +class VoicePayload extends Payload +{ + /** @var string|null */ + protected $token; + + public function __construct(int $op, $d = null, ?int $s = null, ?string $t = null, ?string $token = null) + { + $this->op = $op; + $this->d = $d; + $this->s = $s; + $this->t = $t; + $this->token = $token; + } + + public static function new( + int $op, + $d = null, + ?int $s = null, + ?string $t = null, + ?string $token = null + ): self + { + return new self($op, $d, $s, $t, $token); + } + + public function jsonSerialize(): array + { + $data = parent::jsonSerialize(); + + if (isset($this->token)) { + $data['d']['token'] = $this->token; + } + + return $data; + } + + public function __debugInfo() + { + $array = parent::__debugInfo(); + + if (isset($array['token'])) { + $array['token'] = 'xxxxx'; + } + + return $array; + } +} From 440fae2989437090e64fc0470a2c0af10150b458 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 07:03:53 -0400 Subject: [PATCH 072/121] Implement VoicePayload --- src/Discord/Voice/Voice.php | 3 ++- src/Discord/Voice/VoiceClient.php | 25 +++++++++++++------------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index 2d29a24c2..e54c4d3fb 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -10,6 +10,7 @@ use Discord\WebSockets\Event; use Discord\WebSockets\Op; use Discord\WebSockets\Payload; +use Discord\WebSockets\VoicePayload; use Evenement\EventEmitterTrait; use Psr\Log\LoggerInterface; use Ratchet\Client\WebSocket; @@ -67,7 +68,7 @@ public function createClientAndJoinChannel( $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); - $discord->send(Payload::new( + $discord->send(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, [ 'guild_id' => $channel->guild_id, diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index bc705b883..a0144efa0 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -28,6 +28,7 @@ use Discord\Voice\ReceiveStream; use Discord\WebSockets\Payload; use Discord\WebSockets\Op; +use Discord\WebSockets\VoicePayload; use Evenement\EventEmitter; use Psr\Log\LoggerInterface; use Ratchet\Client\Connector as WsFactory; @@ -463,7 +464,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->heartbeatInterval = $data->d->heartbeat_interval; $sendHeartbeat = function () { - $this->send(Payload::new( + $this->send(VoicePayload::new( Op::VOICE_HEARTBEAT, [ 't' => (int) microtime(true), @@ -613,7 +614,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $ws->on('close', [$this, 'handleWebSocketClose']); if (! $this->sentLoginFrame) { - $payload = Payload::new( + $payload = VoicePayload::new( Op::VOICE_IDENTIFY, [ 'server_id' => $this->channel->guild_id, @@ -1095,7 +1096,7 @@ public function setSpeaking(bool $speaking = true): void throw new \RuntimeException('Voice Client is not ready.'); } - $this->send(Payload::new( + $this->send(VoicePayload::new( Op::VOICE_SPEAKING, [ 'speaking' => $speaking, @@ -1120,7 +1121,7 @@ public function switchChannel(Channel $channel): void throw new \InvalidArgumentException("Channel must be a voice channel to be able to switch, given type {$channel->type}."); } - $this->mainSend(Payload::new( + $this->mainSend(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, [ 'guild_id' => $channel->guild_id, @@ -1203,7 +1204,7 @@ public function setAudioApplication(string $app): void * * @param Payload|array $data The data to send to the voice WebSocket. */ - private function send(Payload|array $data): void + private function send($data): void { $json = json_encode($data); $this->voiceWebsocket->send($json); @@ -1214,7 +1215,7 @@ private function send(Payload|array $data): void * * @param Payload $data The data to send to the main WebSocket. */ - private function mainSend(Payload $data): void + private function mainSend($data): void { $json = json_encode($data); $this->mainWebsocket->send($json); @@ -1237,7 +1238,7 @@ public function setMuteDeaf(bool $mute, bool $deaf): void $this->mute = $mute; $this->deaf = $deaf; - $this->mainSend(Payload::new( + $this->mainSend(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, [ 'guild_id' => $this->channel->guild_id, @@ -1326,7 +1327,7 @@ public function close(): void $this->ready = false; - $this->mainSend(Payload::new( + $this->mainSend(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, [ 'guild_id' => $this->channel->guild_id, @@ -1603,7 +1604,7 @@ private function handleDavePrepareTransition($data) { $this->logger->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition - $this->send(Payload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_TRANSITION_READY, [ 'transition_id' => $data->d->transition_id, @@ -1628,7 +1629,7 @@ private function handleDavePrepareEpoch($data) { $this->logger->debug('DAVE Prepare Epoch', ['data' => $data]); // Prepare local MLS group with parameters appropriate for the DAVE protocol version - $this->send(Payload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, [ 'epoch_id' => $data->d->epoch_id, @@ -1653,7 +1654,7 @@ private function handleDaveMlsProposals($data) { $this->logger->debug('DAVE MLS Proposals', ['data' => $data]); // Handle MLS proposals - $this->send(Payload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_COMMIT_WELCOME, [ 'commit' => $this->generateCommit(), @@ -1685,7 +1686,7 @@ private function handleDaveMlsInvalidCommitWelcome($data) $this->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); // Handle invalid commit or welcome message // Reset local group state and generate a new key package - $this->send(Payload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, [ 'key_package' => $this->generateKeyPackage(), From b060c3d62afd8954ec8ad384d5d0f134325d39e7 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sat, 14 Jun 2025 07:05:24 -0400 Subject: [PATCH 073/121] VoicePayload token getter and setter --- src/Discord/WebSockets/VoicePayload.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Discord/WebSockets/VoicePayload.php b/src/Discord/WebSockets/VoicePayload.php index c7c3f1aff..64bffbe2c 100644 --- a/src/Discord/WebSockets/VoicePayload.php +++ b/src/Discord/WebSockets/VoicePayload.php @@ -49,6 +49,18 @@ public static function new( return new self($op, $d, $s, $t, $token); } + public function setToken(?string $token = null): self + { + $this->token = $token; + + return $this; + } + + public function getToken(): ?string + { + return $this->token ?? null; + } + public function jsonSerialize(): array { $data = parent::jsonSerialize(); From 3a2210bceb5834bbaa7f70e220fe75af9c230168 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 04:48:13 -0400 Subject: [PATCH 074/121] Protected visibility, VoiceClient::createDecoder --- src/Discord/Helpers/Buffer.php | 10 +-- src/Discord/Voice/Voice.php | 7 +- src/Discord/Voice/VoiceClient.php | 118 +++++++++++++++--------------- src/Discord/Voice/VoicePacket.php | 6 +- 4 files changed, 72 insertions(+), 69 deletions(-) diff --git a/src/Discord/Helpers/Buffer.php b/src/Discord/Helpers/Buffer.php index ed7ac6cfd..535c2c24d 100644 --- a/src/Discord/Helpers/Buffer.php +++ b/src/Discord/Helpers/Buffer.php @@ -30,21 +30,21 @@ class Buffer extends EventEmitter implements WritableStreamInterface * * @var string */ - private $buffer = ''; + protected $buffer = ''; /** * Array of deferred reads waiting to be resolved. * * @var Deferred[]|int[] */ - private $reads = []; + protected $reads = []; /** * Whether the buffer has been closed. * * @var bool */ - private $closed = false; + protected $closed = false; /** * ReactPHP event loop. @@ -52,7 +52,7 @@ class Buffer extends EventEmitter implements WritableStreamInterface * * @var LoopInterface */ - private $loop; + protected $loop; public function __construct(LoopInterface $loop = null) { @@ -88,7 +88,7 @@ public function write($data): bool * * @return string|bool The bytes read, or false if not enough bytes are present. */ - private function readRaw(int $length) + protected function readRaw(int $length) { if (strlen($this->buffer) >= $length) { $output = substr($this->buffer, 0, $length); diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index e54c4d3fb..9daa0f004 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -9,7 +9,6 @@ use Discord\Voice\VoiceClient; use Discord\WebSockets\Event; use Discord\WebSockets\Op; -use Discord\WebSockets\Payload; use Discord\WebSockets\VoicePayload; use Evenement\EventEmitterTrait; use Psr\Log\LoggerInterface; @@ -90,7 +89,7 @@ public function getClient(string $guildId): ?VoiceClient return $this->clients[$guildId]; } - private function stateUpdate($state, $channel): void + protected function stateUpdate($state, $channel): void { if ($state->guild_id != $channel->guild_id) { return; // This voice state update isn't for our guild. @@ -100,7 +99,7 @@ private function stateUpdate($state, $channel): void $this->logger->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $state->session_id]); } - private function serverUpdate($state, Channel $channel, $discord, Deferred $deferred): void + protected function serverUpdate($state, Channel $channel, $discord, Deferred $deferred): void { if ($state->guild_id !== $channel->guild_id) { return; // This voice server update isn't for our guild. @@ -141,7 +140,7 @@ private function serverUpdate($state, Channel $channel, $discord, Deferred $defe ->start(); } - private function sendStateUpdate(Channel $channel, bool $mute = false, bool $deaf = true): void + protected function sendStateUpdate(Channel $channel, bool $mute = false, bool $deaf = true): void { $this->botWs->send(json_encode([ 'op' => Op::OP_VOICE_STATE_UPDATE, diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index a0144efa0..bd3811d14 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -347,6 +347,9 @@ class VoiceClient extends EventEmitter */ public $tempFiles; + /** @var TimerInterface */ + public $monitorProcessTimer; + /** * Constructs the Voice client instance * @@ -1045,7 +1048,7 @@ public function playDCAStream($stream): PromiseInterface /** * Resets the voice client. */ - private function reset(): void + protected function reset(): void { if ($this->readOpusTimer) { $this->loop->cancelTimer($this->readOpusTimer); @@ -1065,7 +1068,7 @@ private function reset(): void * @param string $data The data to send to the UDP server. * @todo Fix after new change in VoicePacket */ - private function sendBuffer(string $data): void + protected function sendBuffer(string $data): void { if (! $this->ready) { return; @@ -1204,7 +1207,7 @@ public function setAudioApplication(string $app): void * * @param Payload|array $data The data to send to the voice WebSocket. */ - private function send($data): void + protected function send($data): void { $json = json_encode($data); $this->voiceWebsocket->send($json); @@ -1215,7 +1218,7 @@ private function send($data): void * * @param Payload $data The data to send to the main WebSocket. */ - private function mainSend($data): void + protected function mainSend($data): void { $json = json_encode($data); $this->mainWebsocket->send($json); @@ -1509,37 +1512,7 @@ protected function handleAudioData(VoicePacket $voicePacket): void }); } - $createDecoder = function () use (&$createDecoder, $ss) { - $decoder = $this->ffmpegDecode(); - $decoder->start($this->loop); - - // Handle stdout - $stdoutHandle = fopen($this->tempFiles['stdout'], 'r'); - $this->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { - $data = fread($stdoutHandle, 8192); - if ($data) { - $this->receiveStreams[$ss->ssrc]->writePCM($data); - } - }); - - // Handle stderr - $stderrHandle = fopen($this->tempFiles['stderr'], 'r'); - $this->loop->addPeriodicTimer(0.1, function () use ($stderrHandle, $ss) { - $data = fread($stderrHandle, 8192); - if ($data) { - $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); - $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); - } - }); - - // Store the decoder - $this->voiceDecoders[$ss->ssrc] = $decoder; - - // Monitor the process for exit - $this->monitorProcessExit($decoder, $ss, $createDecoder); - }; - - $createDecoder(); + $this->createDecoder($ss); $decoder = $this->voiceDecoders[$voicePacket->getSSRC()] ?? null; } @@ -1555,6 +1528,37 @@ protected function handleAudioData(VoicePacket $voicePacket): void fclose($stdinHandle); } + protected function createDecoder($ss) + { + $decoder = $this->ffmpegDecode(); + $decoder->start($this->loop); + + // Handle stdout + $stdoutHandle = fopen($this->tempFiles['stdout'], 'r'); + $this->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { + $data = fread($stdoutHandle, 8192); + if ($data) { + $this->receiveStreams[$ss->ssrc]->writePCM($data); + } + }); + + // Handle stderr + $stderrHandle = fopen($this->tempFiles['stderr'], 'r'); + $this->loop->addPeriodicTimer(0.1, function () use ($stderrHandle, $ss) { + $data = fread($stderrHandle, 8192); + if ($data) { + $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); + $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); + } + }); + + // Store the decoder + $this->voiceDecoders[$ss->ssrc] = $decoder; + + // Monitor the process for exit + $this->monitorProcessExit($decoder, $ss); + } + /** * Monitor a process for exit and trigger callbacks when it exits * @@ -1562,25 +1566,25 @@ protected function handleAudioData(VoicePacket $voicePacket): void * @param object $ss The speaking status object * @param callable $createDecoder Function to create a new decoder if needed */ - private function monitorProcessExit(Process $process, $ss, callable $createDecoder): void + protected function monitorProcessExit(Process $process, $ss): void { // Store the process ID // $pid = $process->getPid(); // Check every second if the process is still running - $timer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss, &$createDecoder, &$timer) { + $this->monitorProcessTimer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { // Check if the process is still running if (!$process->isRunning()) { // Get the exit code $exitCode = $process->getExitCode(); // Clean up the timer - $this->loop->cancelTimer($timer); + $this->loop->cancelTimer($this->monitorProcessTimer); // If exit code indicates an error, emit event and recreate decoder if ($exitCode > 0) { $this->emit('decoder-error', [$exitCode, null, $ss]); - $createDecoder(); + $this->createDecoder($ss); } // Clean up temporary files @@ -1589,7 +1593,7 @@ private function monitorProcessExit(Process $process, $ss, callable $createDecod }); } - private function cleanupTempFiles(): void + protected function cleanupTempFiles(): void { if (isset($this->tempFiles)) { foreach ($this->tempFiles as $file) { @@ -1600,7 +1604,7 @@ private function cleanupTempFiles(): void } } - private function handleDavePrepareTransition($data) + protected function handleDavePrepareTransition($data) { $this->logger->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition @@ -1612,20 +1616,20 @@ private function handleDavePrepareTransition($data) )); } - private function handleDaveExecuteTransition($data) + protected function handleDaveExecuteTransition($data) { $this->logger->debug('DAVE Execute Transition', ['data' => $data]); // Execute the transition // Update local state to reflect the new protocol context } - private function handleDaveTransitionReady($data) + protected function handleDaveTransitionReady($data) { $this->logger->debug('DAVE Transition Ready', ['data' => $data]); // Handle transition ready state } - private function handleDavePrepareEpoch($data) + protected function handleDavePrepareEpoch($data) { $this->logger->debug('DAVE Prepare Epoch', ['data' => $data]); // Prepare local MLS group with parameters appropriate for the DAVE protocol version @@ -1638,19 +1642,19 @@ private function handleDavePrepareEpoch($data) )); } - private function handleDaveMlsExternalSender($data) + protected function handleDaveMlsExternalSender($data) { $this->logger->debug('DAVE MLS External Sender', ['data' => $data]); // Handle external sender public key and credential } - private function handleDaveMlsKeyPackage($data) + protected function handleDaveMlsKeyPackage($data) { $this->logger->debug('DAVE MLS Key Package', ['data' => $data]); // Handle MLS key package } - private function handleDaveMlsProposals($data) + protected function handleDaveMlsProposals($data) { $this->logger->debug('DAVE MLS Proposals', ['data' => $data]); // Handle MLS proposals @@ -1663,25 +1667,25 @@ private function handleDaveMlsProposals($data) )); } - private function handleDaveMlsCommitWelcome($data) + protected function handleDaveMlsCommitWelcome($data) { $this->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); // Handle MLS commit and welcome messages } - private function handleDaveMlsAnnounceCommitTransition($data) + protected function handleDaveMlsAnnounceCommitTransition($data) { // Handle MLS announce commit transition $this->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); } - private function handleDaveMlsWelcome($data) + protected function handleDaveMlsWelcome($data) { // Handle MLS welcome message $this->logger->debug('DAVE MLS Welcome', ['data' => $data]); } - private function handleDaveMlsInvalidCommitWelcome($data) + protected function handleDaveMlsInvalidCommitWelcome($data) { $this->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); // Handle invalid commit or welcome message @@ -1694,17 +1698,17 @@ private function handleDaveMlsInvalidCommitWelcome($data) )); } - private function generateKeyPackage() + protected function generateKeyPackage() { // Generate and return a new MLS key package } - private function generateCommit() + protected function generateCommit() { // Generate and return an MLS commit message } - private function generateWelcome() + protected function generateWelcome() { // Generate and return an MLS welcome message } @@ -1724,7 +1728,7 @@ public function isReady(): bool * * @return bool Whether FFmpeg is installed or not. */ - private function checkForFFmpeg(): bool + protected function checkForFFmpeg(): bool { $binaries = [ 'ffmpeg', @@ -1750,7 +1754,7 @@ private function checkForFFmpeg(): bool * * @return bool */ - private function checkForLibsodium(): bool + protected function checkForLibsodium(): bool { if (! function_exists('sodium_crypto_secretbox')) { $this->emit('error', [new LibSodiumNotFoundException('libsodium-php could not be found.')]); @@ -1767,7 +1771,7 @@ private function checkForLibsodium(): bool * @param string $executable * @return string|null */ - private static function checkForExecutable(string $executable): ?string + protected static function checkForExecutable(string $executable): ?string { $which = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? 'where' : 'command -v'; $executable = rtrim((string) explode(PHP_EOL, shell_exec("{$which} {$executable}"))[0]); diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php index 0ab631e0f..a84621d78 100644 --- a/src/Discord/Voice/VoicePacket.php +++ b/src/Discord/Voice/VoicePacket.php @@ -129,14 +129,14 @@ class VoicePacket * * @var string */ - private $rawData; + protected $rawData; /** * Current packet header size. May differ depending on the RTP header. * * @var int */ - private $headerSize; + protected $headerSize; /** * Constructs the voice packet. @@ -148,7 +148,7 @@ class VoicePacket * @param bool $encryption Whether the packet should be encrypted. * @param string|null $key The encryption key. */ - public function __construct(?string $data = null, ?int $ssrc = null, ?int $seq = null, ?int $timestamp = null, bool $encryption = false, private ?string $key = null, private ?Logger $log = null) + public function __construct(?string $data = null, ?int $ssrc = null, ?int $seq = null, ?int $timestamp = null, bool $encryption = false, protected ?string $key = null, protected ?Logger $log = null) { $this->unpack($data) ->decrypt(); From 80043ed2ff18eac8476e03eadbff5f283836e7aa Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 04:59:37 -0400 Subject: [PATCH 075/121] VoiceClient::removeDecoder --- src/Discord/Voice/VoiceClient.php | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index bd3811d14..907efcc50 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -1406,18 +1406,6 @@ public function isPaused(): bool */ public function handleVoiceStateUpdate(object $data): void { - $removeDecoder = function ($ss) { - $decoder = $this->voiceDecoders[$ss->ssrc] ?? null; - - if (null === $decoder) { - return; // no voice decoder to remove - } - - $decoder->close(); - unset($this->voiceDecoders[$ss->ssrc]); - unset($this->speakingStatus[$ss->ssrc]); - }; - $ss = $this->speakingStatus->get('user_id', $data->user_id); if (null === $ss) { @@ -1428,7 +1416,20 @@ public function handleVoiceStateUpdate(object $data): void return; // ignore, just a mute/deaf change } - $removeDecoder($ss); + $this->removeDecoder($ss); + } + + protected function removeDecoder($ss) + { + $decoder = $this->voiceDecoders[$ss->ssrc] ?? null; + + if (null === $decoder) { + return; // no voice decoder to remove + } + + $decoder->close(); + unset($this->voiceDecoders[$ss->ssrc]); + unset($this->speakingStatus[$ss->ssrc]); } /** From 2b1e4a8497520c3420c9302ae6fc34541006fc52 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:01:06 -0400 Subject: [PATCH 076/121] VoiceClient::sendHeartbeat --- src/Discord/Voice/VoiceClient.php | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 907efcc50..c84151943 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -465,21 +465,8 @@ public function handleWebSocketConnection(WebSocket $ws): void break; case Op::VOICE_HELLO: $this->heartbeatInterval = $data->d->heartbeat_interval; - - $sendHeartbeat = function () { - $this->send(VoicePayload::new( - Op::VOICE_HEARTBEAT, - [ - 't' => (int) microtime(true), - 'seq_ack' => 10, - ] - )); - $this->logger->debug('sending heartbeat'); - $this->emit('ws-heartbeat', []); - }; - - $sendHeartbeat(); - $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, $sendHeartbeat); + $this->sendHeartbeat(); + $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); break; case Op::VOICE_CLIENTS_CONNECT: # "d" contains an array with ['user_ids' => array] @@ -634,6 +621,19 @@ public function handleWebSocketConnection(WebSocket $ws): void } } + protected function sendHeartbeat(): void + { + $this->send(VoicePayload::new( + Op::VOICE_HEARTBEAT, + [ + 't' => (int) microtime(true), + 'seq_ack' => 10, + ] + )); + $this->logger->debug('sending heartbeat'); + $this->emit('ws-heartbeat', []); + } + /** * Handles a WebSocket error. * From 92376447fb99fac92e2efcc10d64fff5e1a0bd25 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:03:10 -0400 Subject: [PATCH 077/121] VoiceClient::decodeUDP --- src/Discord/Voice/VoiceClient.php | 73 ++++++++++++++++--------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index c84151943..e5ec644ca 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -548,42 +548,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $client->on('error', fn ($e) => $this->emit('udp-error', [$e])); - $decodeUDP = function ($message) use (&$ip, &$port): void { - /** - * Unpacks the message into an array. - * - * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively - * n (unsigned short) | Length | 2 bytes | Length of the following data - * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender - * A64 (string) | Address | 64 bytes | The IP address of the sender - * n (unsigned short) | Port | 2 bytes | The port of the sender - * - * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery - * @see https://www.php.net/manual/en/function.unpack.php - * @see https://www.php.net/manual/en/function.pack.php For the formats - */ - $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); - - $this->ssrc = $unpackedMessageArray['SSRC']; - $ip = $unpackedMessageArray['Address']; - $port = $unpackedMessageArray['Port']; - - $this->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); - - $this->send([ - 'op' => Op::VOICE_SELECT_PROTO, - 'd' => [ - 'protocol' => 'udp', - 'data' => [ - 'address' => $ip, - 'port' => $port, - 'mode' => $this->mode, - ], - ], - ]); - }; - - $client->once('message', $decodeUDP); + $client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); }, function (\Throwable $e): void { $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); @@ -621,6 +586,42 @@ public function handleWebSocketConnection(WebSocket $ws): void } } + protected function decodeUDP($message, string &$ip, string &$port): void + { + /** + * Unpacks the message into an array. + * + * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively + * n (unsigned short) | Length | 2 bytes | Length of the following data + * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender + * A64 (string) | Address | 64 bytes | The IP address of the sender + * n (unsigned short) | Port | 2 bytes | The port of the sender + * + * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery + * @see https://www.php.net/manual/en/function.unpack.php + * @see https://www.php.net/manual/en/function.pack.php For the formats + */ + $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); + + $this->ssrc = $unpackedMessageArray['SSRC']; + $ip = $unpackedMessageArray['Address']; + $port = $unpackedMessageArray['Port']; + + $this->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + + $this->send([ + 'op' => Op::VOICE_SELECT_PROTO, + 'd' => [ + 'protocol' => 'udp', + 'data' => [ + 'address' => $ip, + 'port' => $port, + 'mode' => $this->mode, + ], + ], + ]); + } + protected function sendHeartbeat(): void { $this->send(VoicePayload::new( From da04f27522ec6dc681ddc673bed0fce3ce5a955b Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:09:44 -0400 Subject: [PATCH 078/121] VoiceClient::readDCAOpus --- src/Discord/Voice/VoiceClient.php | 79 ++++++++++++++++--------------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index e5ec644ca..611627b45 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -982,43 +982,6 @@ public function playDCAStream($stream): PromiseInterface $this->buffer->write($d); }); - $readOpus = function () use ($deferred, &$readOpus) { - $this->readOpusTimer = null; - - // If the client is paused, delay by frame size and check again. - if ($this->paused) { - $this->insertSilence(); - $this->readOpusTimer = $this->loop->addTimer($this->frameSize / 1000, $readOpus); - - return; - } - - // Read opus length - $this->buffer->readInt16(1000)->then(function ($opusLength) { - // Read opus data - return $this->buffer->read($opusLength, null, 1000); - })->then(function ($opus) use (&$readOpus) { - $this->sendBuffer($opus); - - // increment sequence - // uint16 overflow protection - if (++$this->seq >= 2 ** 16) { - $this->seq = 0; - } - - // increment timestamp - // uint32 overflow protection - if (($this->timestamp += ($this->frameSize * 48)) >= 2 ** 32) { - $this->timestamp = 0; - } - - $this->readOpusTimer = $this->loop->addTimer(($this->frameSize - 1) / 1000, $readOpus); - }, function () use ($deferred) { - $this->reset(); - $deferred->resolve(null); - }); - }; - $this->setSpeaking(true); // Read magic byte header @@ -1032,7 +995,7 @@ public function playDCAStream($stream): PromiseInterface })->then(function ($jsonLength) { // Read JSON content return $this->buffer->read($jsonLength); - })->then(function ($metadata) use ($readOpus) { + })->then(function ($metadata) use ($deferred) { $metadata = json_decode($metadata, true); if (null !== $metadata) { @@ -1040,12 +1003,50 @@ public function playDCAStream($stream): PromiseInterface } $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->loop->addTimer(0.5, $readOpus); + $this->readOpusTimer = $this->loop->addTimer(0.5, fn () => $this->readDCAOpus($deferred)); }); return $deferred->promise(); } + protected function readDCAOpus(Deferred $deferred) + { + $this->readOpusTimer = null; + + // If the client is paused, delay by frame size and check again. + if ($this->paused) { + $this->insertSilence(); + $this->readOpusTimer = $this->loop->addTimer($this->frameSize / 1000, fn () => $this->readDCAOpus($deferred)); + + return; + } + + // Read opus length + $this->buffer->readInt16(1000)->then(function ($opusLength) { + // Read opus data + return $this->buffer->read($opusLength, null, 1000); + })->then(function ($opus) use ($deferred) { + $this->sendBuffer($opus); + + // increment sequence + // uint16 overflow protection + if (++$this->seq >= 2 ** 16) { + $this->seq = 0; + } + + // increment timestamp + // uint32 overflow protection + if (($this->timestamp += ($this->frameSize * 48)) >= 2 ** 32) { + $this->timestamp = 0; + } + + $this->readOpusTimer = $this->loop->addTimer(($this->frameSize - 1) / 1000, fn () => $this->readDCAOpus($deferred)); + }, function () use ($deferred) { + $this->reset(); + $deferred->resolve(null); + }); + } + /** * Resets the voice client. */ From 23c625df4bf2121264b47c013331380b71b53207 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:14:06 -0400 Subject: [PATCH 079/121] VoiceClient::readOggOpus --- src/Discord/Voice/VoiceClient.php | 85 ++++++++++++++++--------------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 611627b45..3eb981e96 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -871,61 +871,62 @@ public function playOggStream($stream): PromiseInterface $loops = 0; - $readOpus = function () use ($deferred, &$ogg, &$readOpus, &$loops) { - $this->readOpusTimer = null; + $this->setSpeaking(true); - $loops += 1; + OggStream::fromBuffer($this->buffer)->then(function (OggStream $os) use ($deferred, &$ogg, &$loops) { + $ogg = $os; + $this->startTime = microtime(true) + 0.5; + $this->readOpusTimer = $this->loop->addTimer(0.5, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + }); - // If the client is paused, delay by frame size and check again. - if ($this->paused) { - $this->insertSilence(); - $this->readOpusTimer = $this->loop->addTimer($this->frameSize / 1000, $readOpus); + return $deferred->promise(); + } - return; - } + protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) + { + $this->readOpusTimer = null; - $ogg->getPacket()->then(function ($packet) use (&$readOpus, &$loops, $deferred) { - // EOF for Ogg stream. - if (null === $packet) { - $this->reset(); - $deferred->resolve(null); + $loops += 1; - return; - } + // If the client is paused, delay by frame size and check again. + if ($this->paused) { + $this->insertSilence(); + $this->readOpusTimer = $this->loop->addTimer($this->frameSize / 1000, fn () => $this->readOggOpus($deferred, $ogg, $loops)); - // increment sequence - // uint16 overflow protection - if (++$this->seq >= 2 ** 16) { - $this->seq = 0; - } + return; + } - $this->sendBuffer($packet); + $ogg->getPacket()->then(function ($packet) use (&$loops, $deferred) { + // EOF for Ogg stream. + if (null === $packet) { + $this->reset(); + $deferred->resolve(null); - // increment timestamp - // uint32 overflow protection - if (($this->timestamp += ($this->frameSize * 48)) >= 2 ** 32) { - $this->timestamp = 0; - } + return; + } - $nextTime = $this->startTime + (20.0 / 1000.0) * $loops; - $delay = $nextTime - microtime(true); + // increment sequence + // uint16 overflow protection + if (++$this->seq >= 2 ** 16) { + $this->seq = 0; + } - $this->readOpusTimer = $this->loop->addTimer($delay, $readOpus); - }, function ($e) use ($deferred) { - $this->reset(); - $deferred->resolve(null); - }); - }; + $this->sendBuffer($packet); - $this->setSpeaking(true); + // increment timestamp + // uint32 overflow protection + if (($this->timestamp += ($this->frameSize * 48)) >= 2 ** 32) { + $this->timestamp = 0; + } - OggStream::fromBuffer($this->buffer)->then(function (OggStream $os) use ($readOpus, &$ogg) { - $ogg = $os; - $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->loop->addTimer(0.5, $readOpus); - }); + $nextTime = $this->startTime + (20.0 / 1000.0) * $loops; + $delay = $nextTime - microtime(true); - return $deferred->promise(); + $this->readOpusTimer = $this->loop->addTimer($delay, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + }, function ($e) use ($deferred) { + $this->reset(); + $deferred->resolve(null); + }); } /** From dc3562fb926d4dbba50af3a01314b8d30eaf9d38 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:16:06 -0400 Subject: [PATCH 080/121] PHPDocs --- src/Discord/Voice/VoiceClient.php | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 3eb981e96..e8b09d5f2 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -882,7 +882,14 @@ public function playOggStream($stream): PromiseInterface return $deferred->promise(); } - protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) + /** + * Reads Ogg Opus packets and sends them to the voice server. + * + * @param Deferred $deferred The deferred promise. + * @param OggStream $ogg The Ogg stream to read packets from. + * @param int &$loops The number of loops that have been executed. + */ + protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops): void { $this->readOpusTimer = null; @@ -1010,7 +1017,14 @@ public function playDCAStream($stream): PromiseInterface return $deferred->promise(); } - protected function readDCAOpus(Deferred $deferred) + /** + * Reads and processes a single Opus audio frame from a DCA (Discord Compressed Audio) stream. + * + * @param Deferred $deferred A promise that will be resolved when the reading process completes or fails. + * + * @return void + */ + protected function readDCAOpus(Deferred $deferred): void { $this->readOpusTimer = null; From eab918193e92e17e541491856b1cfc5bab75db6e Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:18:33 -0400 Subject: [PATCH 081/121] PHPDocs --- src/Discord/Voice/VoiceClient.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index e8b09d5f2..c47d7bced 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -1436,7 +1436,15 @@ public function handleVoiceStateUpdate(object $data): void $this->removeDecoder($ss); } - protected function removeDecoder($ss) + /** + * Removes and closes the voice decoder associated with the given SSRC. + * + * @param object $ss An object containing the SSRC (Synchronization Source identifier). + * Expected to have a property 'ssrc'. + * + * @return void + */ + protected function removeDecoder($ss): void { $decoder = $this->voiceDecoders[$ss->ssrc] ?? null; From 2db5664dd880c475f676b30b378236a46bb98d67 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:22:05 -0400 Subject: [PATCH 082/121] PHPDocs --- src/Discord/Voice/VoiceClient.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index c47d7bced..f4fd92dea 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -1554,7 +1554,12 @@ protected function handleAudioData(VoicePacket $voicePacket): void fclose($stdinHandle); } - protected function createDecoder($ss) + /** + * Creates and initializes a decoder process for the given stream session. + * + * @param object $ss The stream session object containing information such as SSRC and user ID. + */ + protected function createDecoder($ss): void { $decoder = $this->ffmpegDecode(); $decoder->start($this->loop); From 912032c748069d695e50a3f717379518ce0c9ef0 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Sun, 15 Jun 2025 05:40:09 -0400 Subject: [PATCH 083/121] Discord property for VoiceClient --- src/Discord/Voice/Voice.php | 2 +- src/Discord/Voice/VoiceClient.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index 9daa0f004..1e7d97967 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -118,7 +118,7 @@ protected function serverUpdate($state, Channel $channel, $discord, Deferred $de 'endpoint' => $state->endpoint ]); - $client = new VoiceClient($this->botWs, $this->loop, $channel, $this->logger, $data); + $client = new VoiceClient($discord, $this->botWs, $this->loop, $channel, $this->logger, $data); $client->once('ready', function () use ($client, $deferred, $channel) { $this->logger->info('voice client is ready'); diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index f4fd92dea..294167740 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -13,6 +13,7 @@ namespace Discord\Voice; +use Discord\Discord; use Discord\Exceptions\FFmpegNotFoundException; use Discord\Exceptions\FileNotFoundException; use Discord\Exceptions\LibSodiumNotFoundException; @@ -362,6 +363,7 @@ class VoiceClient extends EventEmitter * @param bool $mute Default: false */ public function __construct( + protected Discord $discord, protected WebSocket $mainWebsocket, protected LoopInterface $loop, protected Channel $channel, From 9150506cecaaa5cc4980d4cf1e38a39bd95d1ec9 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 16 Jun 2025 13:41:47 -0400 Subject: [PATCH 084/121] VoiceSpeaking part, remove unused construct params --- src/Discord/Voice/Voice.php | 2 +- src/Discord/Voice/VoiceClient.php | 145 +++++++++--------- .../WebSockets/EventData/VoiceSpeaking.php | 29 ++++ 3 files changed, 100 insertions(+), 76 deletions(-) create mode 100644 src/Discord/WebSockets/EventData/VoiceSpeaking.php diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/Voice.php index 1e7d97967..df97dedbc 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/Voice.php @@ -118,7 +118,7 @@ protected function serverUpdate($state, Channel $channel, $discord, Deferred $de 'endpoint' => $state->endpoint ]); - $client = new VoiceClient($discord, $this->botWs, $this->loop, $channel, $this->logger, $data); + $client = new VoiceClient($discord, $this->botWs, $channel, $data); $client->once('ready', function () use ($client, $deferred, $channel) { $this->logger->info('voice client is ready'); diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 294167740..4cc08c567 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -27,11 +27,11 @@ use Discord\Parts\Channel\Channel; use Discord\Voice\VoicePacket; use Discord\Voice\ReceiveStream; +use Discord\WebSockets\EventData\VoiceSpeaking; use Discord\WebSockets\Payload; use Discord\WebSockets\Op; use Discord\WebSockets\VoicePayload; use Evenement\EventEmitter; -use Psr\Log\LoggerInterface; use Ratchet\Client\Connector as WsFactory; use Ratchet\Client\WebSocket; use Ratchet\RFC6455\Messaging\Message; @@ -40,7 +40,6 @@ use React\Datagram\Socket; use React\Dns\Config\Config; use React\Dns\Resolver\Factory as DNSFactory; -use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; @@ -233,7 +232,7 @@ class VoiceClient extends EventEmitter /** * Collection of the status of people speaking. * - * @var ExCollectionInterface Status of people speaking. + * @var ExCollectionInterface Status of people speaking. */ protected $speakingStatus; @@ -355,9 +354,7 @@ class VoiceClient extends EventEmitter * Constructs the Voice client instance * * @param \Ratchet\Client\WebSocket $mainWebsocket - * @param \React\EventLoop\LoopInterface $loop * @param \Discord\Parts\Channel\Channel $channel - * @param \Psr\Log\LoggerInterface $logger * @param array $data * @param bool $deaf Default: false * @param bool $mute Default: false @@ -365,9 +362,7 @@ class VoiceClient extends EventEmitter public function __construct( protected Discord $discord, protected WebSocket $mainWebsocket, - protected LoopInterface $loop, protected Channel $channel, - protected LoggerInterface $logger, protected array $data, protected bool $deaf = false, protected bool $mute = false, @@ -375,7 +370,7 @@ public function __construct( $this->deaf = $this->data['deaf'] ?? false; $this->mute = $this->data['mute'] ?? false; $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - $this->speakingStatus = new Collection([], 'ssrc'); + $this->speakingStatus = Collection::for(VoiceSpeaking::class, 'ssrc'); $this->dnsConfig = $data['dnsConfig']; } @@ -402,7 +397,7 @@ public function start(): bool */ public function initSockets(): void { - $wsfac = new WsFactory($this->loop); + $wsfac = new WsFactory($this->discord->loop); /** @var PromiseInterface */ $promise = $wsfac("wss://{$this->endpoint}?v={$this->version}"); @@ -416,10 +411,10 @@ public function initSockets(): void */ public function handleWebSocketConnection(WebSocket $ws): void { - $this->logger->debug('connected to voice websocket'); + $this->discord->logger->debug('connected to voice websocket'); - $resolver = (new DNSFactory())->createCached($this->dnsConfig, $this->loop); - $udpfac = new DatagramFactory($this->loop, $resolver); + $resolver = (new DNSFactory())->createCached($this->dnsConfig, $this->discord->loop); + $udpfac = new DatagramFactory($this->discord->loop, $resolver); $this->voiceWebsocket = $ws; @@ -435,7 +430,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $start = $data->d->t; $diff = ($end - $start) * 1000; - $this->logger->debug('received heartbeat ack', ['response_time' => $diff]); + $this->discord->logger->debug('received heartbeat ack', ['response_time' => $diff]); $this->emit('ws-ping', [$diff]); $this->emit('ws-heartbeat-ack', [$data->d->t]); break; @@ -446,7 +441,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->rawKey = $data->d->secret_key; $this->secretKey = implode('', array_map(fn ($value) => pack('C', $value), $this->rawKey)); - $this->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); + $this->discord->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); if (! $this->reconnecting) { $this->emit('ready', [$this]); @@ -456,19 +451,19 @@ public function handleWebSocketConnection(WebSocket $ws): void } if (! $this->deaf && $this->secretKey) { - $this->client->on('message', fn (string $message) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->logger))); + $this->client->on('message', fn (string $message) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->discord->logger))); } break; case Op::VOICE_SPEAKING: // currently connected users $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); - $this->speakingStatus[$data->d->user_id] = $data->d; + $this->speakingStatus[$data->d->user_id] = $this->discord->factory->create(VoiceSpeaking::class, $data->d); break; case Op::VOICE_HELLO: $this->heartbeatInterval = $data->d->heartbeat_interval; $this->sendHeartbeat(); - $this->heartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); + $this->heartbeat = $this->discord->loop->addPeriodicTimer($this->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); break; case Op::VOICE_CLIENTS_CONNECT: # "d" contains an array with ['user_ids' => array] @@ -479,7 +474,7 @@ public function handleWebSocketConnection(WebSocket $ws): void break; case Op::VOICE_CLIENT_UNKNOWN_15: case Op::VOICE_CLIENT_UNKNOWN_18: - $this->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + $this->discord->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); break; case Op::VOICE_CLIENT_PLATFORM: # handlePlatformPerUser @@ -523,7 +518,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->udpPort = $data->d->port; $this->ssrc = $data->d->ssrc; - $this->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->discord->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); $buffer = new Buffer(74); $buffer[1] = "\x01"; @@ -531,12 +526,12 @@ public function handleWebSocketConnection(WebSocket $ws): void $buffer->writeUInt32BE($this->ssrc, 4); /** @var PromiseInterface */ $udpfac->createClient("{$data->d->ip}:{$this->udpPort}")->then(function (Socket $client) use (&$ip, &$port, $buffer): void { - $this->logger->debug('connected to voice UDP'); + $this->discord->logger->debug('connected to voice UDP'); $this->client = $client; - $this->loop->addTimer(0.1, fn () => $this->client->send($buffer->__toString())); + $this->discord->loop->addTimer(0.1, fn () => $this->client->send($buffer->__toString())); - $this->udpHeartbeat = $this->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { + $this->udpHeartbeat = $this->discord->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { $buffer = new Buffer(9); $buffer[0] = 0xC9; $buffer->writeUInt64LE($this->heartbeatSeq, 1); @@ -545,26 +540,26 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->client->send($buffer->__toString()); $this->emit('udp-heartbeat', []); - $this->logger->debug('sent UDP heartbeat'); + $this->discord->logger->debug('sent UDP heartbeat'); }); $client->on('error', fn ($e) => $this->emit('udp-error', [$e])); $client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); }, function (\Throwable $e): void { - $this->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); + $this->discord->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); }); break; } default: - $this->logger->warning('Unknown opcode.', $data); + $this->discord->logger->warning('Unknown opcode.', $data); break; } }); $ws->on('error', function ($e): void { - $this->logger->error('error with voice websocket', ['e' => $e->getMessage()]); + $this->discord->logger->error('error with voice websocket', ['e' => $e->getMessage()]); $this->emit('ws-error', [$e]); }); @@ -581,7 +576,7 @@ public function handleWebSocketConnection(WebSocket $ws): void ], ); - $this->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); + $this->discord->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); $this->send($payload); $this->sentLoginFrame = true; @@ -609,7 +604,7 @@ protected function decodeUDP($message, string &$ip, string &$port): void $ip = $unpackedMessageArray['Address']; $port = $unpackedMessageArray['Port']; - $this->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + $this->discord->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); $this->send([ 'op' => Op::VOICE_SELECT_PROTO, @@ -633,7 +628,7 @@ protected function sendHeartbeat(): void 'seq_ack' => 10, ] )); - $this->logger->debug('sending heartbeat'); + $this->discord->logger->debug('sending heartbeat'); $this->emit('ws-heartbeat', []); } @@ -644,7 +639,7 @@ protected function sendHeartbeat(): void */ public function handleWebSocketError(\Exception $e): void { - $this->logger->error('error with voice websocket', ['e' => $e->getMessage()]); + $this->discord->logger->error('error with voice websocket', ['e' => $e->getMessage()]); $this->emit('error', [$e]); } @@ -656,37 +651,37 @@ public function handleWebSocketError(\Exception $e): void */ public function handleWebSocketClose(int $op, string $reason): void { - $this->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); + $this->discord->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); $this->emit('ws-close', [$op, $reason, $this]); $this->clientsConnected = []; // Cancel heartbeat timers if (null !== $this->heartbeat) { - $this->loop->cancelTimer($this->heartbeat); + $this->discord->loop->cancelTimer($this->heartbeat); $this->heartbeat = null; } if (null !== $this->udpHeartbeat) { - $this->loop->cancelTimer($this->udpHeartbeat); + $this->discord->loop->cancelTimer($this->udpHeartbeat); $this->udpHeartbeat = null; } // Close UDP socket. if (isset($this->client)) { - $this->logger->warning('closing UDP client'); + $this->discord->logger->warning('closing UDP client'); $this->client->close(); } // Don't reconnect on a critical opcode or if closed by user. if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this->userClose) { - $this->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); + $this->discord->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); $this->emit('close'); } else { - $this->logger->warning('reconnecting in 2 seconds'); + $this->discord->logger->warning('reconnecting in 2 seconds'); // Retry connect after 2 seconds - $this->loop->addTimer(2, function (): void { + $this->discord->loop->addTimer(2, function (): void { $this->reconnecting = true; $this->sentLoginFrame = false; @@ -702,7 +697,7 @@ public function handleWebSocketClose(int $op, string $reason): void */ public function handleVoiceServerChange(array $data = []): void { - $this->logger->debug('voice server has changed, dynamically changing servers in the background', ['data' => $data]); + $this->discord->logger->debug('voice server has changed, dynamically changing servers in the background', ['data' => $data]); $this->reconnecting = true; $this->sentLoginFrame = false; $this->pause(); @@ -710,8 +705,8 @@ public function handleVoiceServerChange(array $data = []): void $this->client->close(); $this->voiceWebsocket->close(); - $this->loop->cancelTimer($this->heartbeat); - $this->loop->cancelTimer($this->udpHeartbeat); + $this->discord->loop->cancelTimer($this->heartbeat); + $this->discord->loop->cancelTimer($this->udpHeartbeat); $this->data['token'] = $data['token']; // set the token if it changed $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); @@ -719,7 +714,7 @@ public function handleVoiceServerChange(array $data = []): void $this->initSockets(); $this->on('resumed', function () { - $this->logger->debug('voice client resumed'); + $this->discord->logger->debug('voice client resumed'); $this->unpause(); $this->speaking = false; $this->setSpeaking(true); @@ -761,7 +756,7 @@ public function playFile(string $file, int $channels = 2): PromiseInterface } $process = $this->ffmpegEncode($file); - $process->start($this->loop); + $process->start($this->discord->loop); return $this->playOggStream($process); } @@ -801,7 +796,7 @@ public function playRawStream($stream, int $channels = 2, int $audioRate = 48000 } if (is_resource($stream)) { - $stream = new Stream($stream, $this->loop); + $stream = new Stream($stream, $this->discord->loop); } $process = $this->ffmpegEncode(preArgs: [ @@ -809,7 +804,7 @@ public function playRawStream($stream, int $channels = 2, int $audioRate = 48000 '-ac', $channels, '-ar', $audioRate, ]); - $process->start($this->loop); + $process->start($this->discord->loop); $stream->pipe($process->stdin); return $this->playOggStream($process); @@ -854,7 +849,7 @@ public function playOggStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new Stream($stream, $this->loop); + $stream = new Stream($stream, $this->discord->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -863,7 +858,7 @@ public function playOggStream($stream): PromiseInterface return $deferred->promise(); } - $this->buffer = new RealBuffer($this->loop); + $this->buffer = new RealBuffer($this->discord->loop); $stream->on('data', function ($d) { $this->buffer->write($d); }); @@ -878,7 +873,7 @@ public function playOggStream($stream): PromiseInterface OggStream::fromBuffer($this->buffer)->then(function (OggStream $os) use ($deferred, &$ogg, &$loops) { $ogg = $os; $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->loop->addTimer(0.5, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + $this->readOpusTimer = $this->discord->loop->addTimer(0.5, fn () => $this->readOggOpus($deferred, $ogg, $loops)); }); return $deferred->promise(); @@ -900,7 +895,7 @@ protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) // If the client is paused, delay by frame size and check again. if ($this->paused) { $this->insertSilence(); - $this->readOpusTimer = $this->loop->addTimer($this->frameSize / 1000, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + $this->readOpusTimer = $this->discord->loop->addTimer($this->frameSize / 1000, fn () => $this->readOggOpus($deferred, $ogg, $loops)); return; } @@ -931,7 +926,7 @@ protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) $nextTime = $this->startTime + (20.0 / 1000.0) * $loops; $delay = $nextTime - microtime(true); - $this->readOpusTimer = $this->loop->addTimer($delay, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + $this->readOpusTimer = $this->discord->loop->addTimer($delay, fn () => $this->readOggOpus($deferred, $ogg, $loops)); }, function ($e) use ($deferred) { $this->reset(); $deferred->resolve(null); @@ -978,7 +973,7 @@ public function playDCAStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new Stream($stream, $this->loop); + $stream = new Stream($stream, $this->discord->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -987,7 +982,7 @@ public function playDCAStream($stream): PromiseInterface return $deferred->promise(); } - $this->buffer = new RealBuffer($this->loop); + $this->buffer = new RealBuffer($this->discord->loop); $stream->on('data', function ($d) { $this->buffer->write($d); }); @@ -1013,7 +1008,7 @@ public function playDCAStream($stream): PromiseInterface } $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->loop->addTimer(0.5, fn () => $this->readDCAOpus($deferred)); + $this->readOpusTimer = $this->discord->loop->addTimer(0.5, fn () => $this->readDCAOpus($deferred)); }); return $deferred->promise(); @@ -1033,7 +1028,7 @@ protected function readDCAOpus(Deferred $deferred): void // If the client is paused, delay by frame size and check again. if ($this->paused) { $this->insertSilence(); - $this->readOpusTimer = $this->loop->addTimer($this->frameSize / 1000, fn () => $this->readDCAOpus($deferred)); + $this->readOpusTimer = $this->discord->loop->addTimer($this->frameSize / 1000, fn () => $this->readDCAOpus($deferred)); return; } @@ -1057,7 +1052,7 @@ protected function readDCAOpus(Deferred $deferred): void $this->timestamp = 0; } - $this->readOpusTimer = $this->loop->addTimer(($this->frameSize - 1) / 1000, fn () => $this->readDCAOpus($deferred)); + $this->readOpusTimer = $this->discord->loop->addTimer(($this->frameSize - 1) / 1000, fn () => $this->readDCAOpus($deferred)); }, function () use ($deferred) { $this->reset(); $deferred->resolve(null); @@ -1070,7 +1065,7 @@ protected function readDCAOpus(Deferred $deferred): void protected function reset(): void { if ($this->readOpusTimer) { - $this->loop->cancelTimer($this->readOpusTimer); + $this->discord->loop->cancelTimer($this->readOpusTimer); $this->readOpusTimer = null; } @@ -1366,12 +1361,12 @@ public function close(): void $this->heartbeatInterval = null; if (null !== $this->heartbeat) { - $this->loop->cancelTimer($this->heartbeat); + $this->discord->loop->cancelTimer($this->heartbeat); $this->heartbeat = null; } if (null !== $this->udpHeartbeat) { - $this->loop->cancelTimer($this->udpHeartbeat); + $this->discord->loop->cancelTimer($this->udpHeartbeat); $this->udpHeartbeat = null; } @@ -1511,7 +1506,7 @@ protected function handleAudioData(VoicePacket $voicePacket): void return; } // There's no message or the message threw an error inside the decrypt function - $this->logger->warning('No audio data.', ['voicePacket' => $voicePacket]); + $this->discord->logger->warning('No audio data.', ['voicePacket' => $voicePacket]); return; } @@ -1522,7 +1517,7 @@ protected function handleAudioData(VoicePacket $voicePacket): void if (null === $ss) { // for some reason we don't have a speaking status - $this->logger->warning('Unknown SSRC.', ['ssrc' => $voicePacket->getSSRC(), 't' => $voicePacket->getTimestamp()]); + $this->discord->logger->warning('Unknown SSRC.', ['ssrc' => $voicePacket->getSSRC(), 't' => $voicePacket->getTimestamp()]); return; } @@ -1564,11 +1559,11 @@ protected function handleAudioData(VoicePacket $voicePacket): void protected function createDecoder($ss): void { $decoder = $this->ffmpegDecode(); - $decoder->start($this->loop); + $decoder->start($this->discord->loop); // Handle stdout $stdoutHandle = fopen($this->tempFiles['stdout'], 'r'); - $this->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { + $this->discord->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { $data = fread($stdoutHandle, 8192); if ($data) { $this->receiveStreams[$ss->ssrc]->writePCM($data); @@ -1577,7 +1572,7 @@ protected function createDecoder($ss): void // Handle stderr $stderrHandle = fopen($this->tempFiles['stderr'], 'r'); - $this->loop->addPeriodicTimer(0.1, function () use ($stderrHandle, $ss) { + $this->discord->loop->addPeriodicTimer(0.1, function () use ($stderrHandle, $ss) { $data = fread($stderrHandle, 8192); if ($data) { $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); @@ -1605,14 +1600,14 @@ protected function monitorProcessExit(Process $process, $ss): void // $pid = $process->getPid(); // Check every second if the process is still running - $this->monitorProcessTimer = $this->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { + $this->monitorProcessTimer = $this->discord->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { // Check if the process is still running if (!$process->isRunning()) { // Get the exit code $exitCode = $process->getExitCode(); // Clean up the timer - $this->loop->cancelTimer($this->monitorProcessTimer); + $this->discord->loop->cancelTimer($this->monitorProcessTimer); // If exit code indicates an error, emit event and recreate decoder if ($exitCode > 0) { @@ -1639,7 +1634,7 @@ protected function cleanupTempFiles(): void protected function handleDavePrepareTransition($data) { - $this->logger->debug('DAVE Prepare Transition', ['data' => $data]); + $this->discord->logger->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition $this->send(VoicePayload::new( Op::VOICE_DAVE_TRANSITION_READY, @@ -1651,20 +1646,20 @@ protected function handleDavePrepareTransition($data) protected function handleDaveExecuteTransition($data) { - $this->logger->debug('DAVE Execute Transition', ['data' => $data]); + $this->discord->logger->debug('DAVE Execute Transition', ['data' => $data]); // Execute the transition // Update local state to reflect the new protocol context } protected function handleDaveTransitionReady($data) { - $this->logger->debug('DAVE Transition Ready', ['data' => $data]); + $this->discord->logger->debug('DAVE Transition Ready', ['data' => $data]); // Handle transition ready state } protected function handleDavePrepareEpoch($data) { - $this->logger->debug('DAVE Prepare Epoch', ['data' => $data]); + $this->discord->logger->debug('DAVE Prepare Epoch', ['data' => $data]); // Prepare local MLS group with parameters appropriate for the DAVE protocol version $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, @@ -1677,19 +1672,19 @@ protected function handleDavePrepareEpoch($data) protected function handleDaveMlsExternalSender($data) { - $this->logger->debug('DAVE MLS External Sender', ['data' => $data]); + $this->discord->logger->debug('DAVE MLS External Sender', ['data' => $data]); // Handle external sender public key and credential } protected function handleDaveMlsKeyPackage($data) { - $this->logger->debug('DAVE MLS Key Package', ['data' => $data]); + $this->discord->logger->debug('DAVE MLS Key Package', ['data' => $data]); // Handle MLS key package } protected function handleDaveMlsProposals($data) { - $this->logger->debug('DAVE MLS Proposals', ['data' => $data]); + $this->discord->logger->debug('DAVE MLS Proposals', ['data' => $data]); // Handle MLS proposals $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_COMMIT_WELCOME, @@ -1702,25 +1697,25 @@ protected function handleDaveMlsProposals($data) protected function handleDaveMlsCommitWelcome($data) { - $this->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); + $this->discord->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); // Handle MLS commit and welcome messages } protected function handleDaveMlsAnnounceCommitTransition($data) { // Handle MLS announce commit transition - $this->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); + $this->discord->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); } protected function handleDaveMlsWelcome($data) { // Handle MLS welcome message - $this->logger->debug('DAVE MLS Welcome', ['data' => $data]); + $this->discord->logger->debug('DAVE MLS Welcome', ['data' => $data]); } protected function handleDaveMlsInvalidCommitWelcome($data) { - $this->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); + $this->discord->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); // Handle invalid commit or welcome message // Reset local group state and generate a new key package $this->send(VoicePayload::new( diff --git a/src/Discord/WebSockets/EventData/VoiceSpeaking.php b/src/Discord/WebSockets/EventData/VoiceSpeaking.php new file mode 100644 index 000000000..db6a10c41 --- /dev/null +++ b/src/Discord/WebSockets/EventData/VoiceSpeaking.php @@ -0,0 +1,29 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\WebSockets\EventData; + +use Discord\Parts\Part; + +class VoiceSpeaking extends Part +{ + /** + * {@inheritDoc} + */ + protected $fillable = [ + 'user_id', // undocumented + 'ssrc', + 'speaking', + 'delay', // Should be set to 0 for bots, but may not be set at all on incoming payloads + ]; +} From 0b25c37384e5e9e4bd1186c1b65cea57384a6840 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Mon, 16 Jun 2025 14:58:10 -0400 Subject: [PATCH 085/121] Discord\WebSockets\EventData => Discord\Parts\EventData --- src/Discord/{WebSockets => Parts}/EventData/VoiceSpeaking.php | 2 +- src/Discord/Voice/VoiceClient.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/Discord/{WebSockets => Parts}/EventData/VoiceSpeaking.php (93%) diff --git a/src/Discord/WebSockets/EventData/VoiceSpeaking.php b/src/Discord/Parts/EventData/VoiceSpeaking.php similarity index 93% rename from src/Discord/WebSockets/EventData/VoiceSpeaking.php rename to src/Discord/Parts/EventData/VoiceSpeaking.php index db6a10c41..334b1e862 100644 --- a/src/Discord/WebSockets/EventData/VoiceSpeaking.php +++ b/src/Discord/Parts/EventData/VoiceSpeaking.php @@ -11,7 +11,7 @@ * with this source code in the LICENSE.md file. */ -namespace Discord\WebSockets\EventData; +namespace Discord\Parts\EventData; use Discord\Parts\Part; diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 4cc08c567..1c885db11 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -27,7 +27,7 @@ use Discord\Parts\Channel\Channel; use Discord\Voice\VoicePacket; use Discord\Voice\ReceiveStream; -use Discord\WebSockets\EventData\VoiceSpeaking; +use Discord\Parts\EventData\VoiceSpeaking; use Discord\WebSockets\Payload; use Discord\WebSockets\Op; use Discord\WebSockets\VoicePayload; From d131fba07ae3837afc8144a79583ea16b8f46aad Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 16 Jun 2025 21:16:13 +0100 Subject: [PATCH 086/121] Fixes issue on factory being null --- src/Discord/Voice/VoiceClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 1c885db11..6ee89278c 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -458,7 +458,7 @@ public function handleWebSocketConnection(WebSocket $ws): void case Op::VOICE_SPEAKING: // currently connected users $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); - $this->speakingStatus[$data->d->user_id] = $this->discord->factory->create(VoiceSpeaking::class, $data->d); + $this->speakingStatus[$data->d->user_id] = $this->discord->getFactory()->create(VoiceSpeaking::class, $data->d); break; case Op::VOICE_HELLO: $this->heartbeatInterval = $data->d->heartbeat_interval; From f988bd632c2926b87153b8fbb836fdc31a623759 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 17 Jun 2025 09:55:11 +0100 Subject: [PATCH 087/121] Updates Voice class to VoiceManager --- src/Discord/Discord.php | 7 ++++--- src/Discord/Voice/{Voice.php => VoiceManager.php} | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) rename src/Discord/Voice/{Voice.php => VoiceManager.php} (99%) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 3775f4a15..90b68ea69 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -36,6 +36,7 @@ use Discord\Repository\UserRepository; use Discord\Voice\Voice; use Discord\Voice\VoiceClient; +use Discord\Voice\VoiceManager; use Discord\WebSockets\Event; use Discord\WebSockets\Events\GuildCreate; use Discord\WebSockets\Handlers; @@ -336,9 +337,9 @@ class Discord /** * The voice handler, of clients and packets. * - * @var Voice + * @var VoiceManager */ - public Voice $voice; + public VoiceManager $voice; /** * The transport compression setting. @@ -1177,7 +1178,7 @@ protected function ready() } $this->emittedInit = true; - $this->voice = new Voice($this->ws, $this->loop, $this->logger, (int) $this?->id ?? $this->client->id); + $this->voice = new VoiceManager($this->ws, $this->loop, $this->logger, (int) $this?->id ?? $this->client->id); $this->logger->info('voice class initialized'); $this->logger->info('client is ready'); diff --git a/src/Discord/Voice/Voice.php b/src/Discord/Voice/VoiceManager.php similarity index 99% rename from src/Discord/Voice/Voice.php rename to src/Discord/Voice/VoiceManager.php index df97dedbc..01a764b92 100644 --- a/src/Discord/Voice/Voice.php +++ b/src/Discord/Voice/VoiceManager.php @@ -16,7 +16,7 @@ use React\EventLoop\LoopInterface; use React\Promise\Deferred; -final class Voice +final class VoiceManager { use EventEmitterTrait; From 4fd3190951776868b6bff320d7259b1fe1c44862 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 17 Jun 2025 10:08:34 +0100 Subject: [PATCH 088/121] Simplifies some code to remove multiple usages of the same class e.g. ws, logger and loop and just use Discord class --- src/Discord/Discord.php | 9 +++++++-- src/Discord/Voice/VoiceManager.php | 32 ++++++++++++------------------ 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 90b68ea69..e880f9e82 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -1178,7 +1178,7 @@ protected function ready() } $this->emittedInit = true; - $this->voice = new VoiceManager($this->ws, $this->loop, $this->logger, (int) $this?->id ?? $this->client->id); + $this->voice = new VoiceManager($this); $this->logger->info('voice class initialized'); $this->logger->info('client is ready'); @@ -1555,7 +1555,7 @@ public function getLogger(): LoggerInterface */ public function getHttp(): Http { - return $this->http; + return $this->getHttpClient(); } /** @@ -1705,4 +1705,9 @@ public function __debugInfo(): array return $config; } + + public function getWs(): ?WebSocket + { + return $this->ws; + } } diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index 01a764b92..356160dd9 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -21,17 +21,11 @@ final class VoiceManager use EventEmitterTrait; /** - * @param \Ratchet\Client\WebSocket $botWs - * @param \React\EventLoop\LoopInterface $loop - * @param \Psr\Log\LoggerInterface $logger - * @param int $botId + * @param Discord $bot * @param array $clients */ public function __construct( - protected WebSocket $botWs, - protected LoopInterface $loop, - protected LoggerInterface $logger, - protected int $botId, + protected Discord $bot, public array $clients = [], ) { } @@ -59,13 +53,13 @@ public function createClientAndJoinChannel( $this->clients[$channel->guild_id] = ['data' => []]; $this->clients[$channel->guild_id]['data'] = [ - 'user_id' => $this->botId, + 'user_id' => $this->bot->id, 'deaf' => $deaf, 'mute' => $mute, ]; $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); - $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); + $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, Discord $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); $discord->send(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, @@ -96,10 +90,10 @@ protected function stateUpdate($state, $channel): void } $this->clients[$channel->guild_id]['data']['session'] = $state->session_id; - $this->logger->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $state->session_id]); + $this->bot->getLogger()->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $state->session_id]); } - protected function serverUpdate($state, Channel $channel, $discord, Deferred $deferred): void + protected function serverUpdate($state, Channel $channel, Discord $discord, Deferred $deferred): void { if ($state->guild_id !== $channel->guild_id) { return; // This voice server update isn't for our guild. @@ -112,29 +106,29 @@ protected function serverUpdate($state, Channel $channel, $discord, Deferred $de $data['endpoint'] = $state->endpoint; $data['dnsConfig'] = $discord->options['dnsConfig']; - $this->logger->info('received token and endpoint for voice session', [ + $this->bot->getLogger()->info('received token and endpoint for voice session', [ 'guild' => $channel->guild_id, 'token' => $state->token, 'endpoint' => $state->endpoint ]); - $client = new VoiceClient($discord, $this->botWs, $channel, $data); + $client = new VoiceClient($discord, $this->bot->getWs(), $channel, $data); $client->once('ready', function () use ($client, $deferred, $channel) { - $this->logger->info('voice client is ready'); + $this->bot->getLogger()->info('voice client is ready'); $this->clients[$channel->guild_id] = $client; $client->setBitrate($channel->bitrate); - $this->logger->info('set voice client bitrate', ['bitrate' => $channel->bitrate]); + $this->bot->getLogger()->info('set voice client bitrate', ['bitrate' => $channel->bitrate]); $deferred->resolve($client); }) ->once('error', function ($e) use ($deferred) { - $this->logger->error('error initializing voice client', ['e' => $e->getMessage()]); + $this->bot->getLogger()->error('error initializing voice client', ['e' => $e->getMessage()]); $deferred->reject($e); }) ->once('close', function () use ($channel) { - $this->logger->warning('voice client closed'); + $this->bot->getLogger()->warning('voice client closed'); unset($this->clients[$channel->guild_id]); }) ->start(); @@ -142,7 +136,7 @@ protected function serverUpdate($state, Channel $channel, $discord, Deferred $de protected function sendStateUpdate(Channel $channel, bool $mute = false, bool $deaf = true): void { - $this->botWs->send(json_encode([ + $this->bot->ws->send(json_encode([ 'op' => Op::OP_VOICE_STATE_UPDATE, 'd' => [ 'guild_id' => $channel->guild_id, From 51a6a049e93e670a52d1f2f043e51b37f3f3b244 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 17 Jun 2025 10:19:38 +0100 Subject: [PATCH 089/121] Updates more usages for $this->bot instead of using other names, which might confuse some --- src/Discord/Voice/VoiceClient.php | 230 +++++++++++++++++------------- 1 file changed, 131 insertions(+), 99 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 6ee89278c..70c50892a 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -18,18 +18,19 @@ use Discord\Exceptions\FileNotFoundException; use Discord\Exceptions\LibSodiumNotFoundException; use Discord\Exceptions\OutdatedDCAException; -use Discord\Exceptions\Voice\ClientNotReadyException; use Discord\Exceptions\Voice\AudioAlreadyPlayingException; +use Discord\Exceptions\Voice\ClientNotReadyException; use Discord\Helpers\Buffer as RealBuffer; use Discord\Helpers\ByteBuffer\Buffer; use Discord\Helpers\Collection; use Discord\Helpers\ExCollectionInterface; use Discord\Parts\Channel\Channel; -use Discord\Voice\VoicePacket; -use Discord\Voice\ReceiveStream; use Discord\Parts\EventData\VoiceSpeaking; -use Discord\WebSockets\Payload; +use Discord\Voice\Client\User; +use Discord\Voice\ReceiveStream; +use Discord\Voice\VoicePacket; use Discord\WebSockets\Op; +use Discord\WebSockets\Payload; use Discord\WebSockets\VoicePayload; use Evenement\EventEmitter; use Ratchet\Client\Connector as WsFactory; @@ -253,7 +254,7 @@ class VoiceClient extends EventEmitter protected $recieveStreams; /** - * Voice audio recieve streams. + * Voice audio receive streams. * * @var array|null Voice audio recieve streams. */ @@ -338,7 +339,7 @@ class VoiceClient extends EventEmitter * * @var array */ - public $clientsConnected = []; + public array $clientsConnected = []; /** * Temporary files. @@ -350,18 +351,24 @@ class VoiceClient extends EventEmitter /** @var TimerInterface */ public $monitorProcessTimer; + /** + * Users in the current voice channel. + * + * @var array Users in the current voice channel. + */ + public array $users; + /** * Constructs the Voice client instance * - * @param \Ratchet\Client\WebSocket $mainWebsocket + * @param \Discord\Discord $bot The Discord instance. * @param \Discord\Parts\Channel\Channel $channel * @param array $data * @param bool $deaf Default: false * @param bool $mute Default: false */ public function __construct( - protected Discord $discord, - protected WebSocket $mainWebsocket, + protected Discord $bot, protected Channel $channel, protected array $data, protected bool $deaf = false, @@ -397,7 +404,7 @@ public function start(): bool */ public function initSockets(): void { - $wsfac = new WsFactory($this->discord->loop); + $wsfac = new WsFactory($this->bot->loop); /** @var PromiseInterface */ $promise = $wsfac("wss://{$this->endpoint}?v={$this->version}"); @@ -411,10 +418,10 @@ public function initSockets(): void */ public function handleWebSocketConnection(WebSocket $ws): void { - $this->discord->logger->debug('connected to voice websocket'); + $this->bot->logger->debug('connected to voice websocket'); - $resolver = (new DNSFactory())->createCached($this->dnsConfig, $this->discord->loop); - $udpfac = new DatagramFactory($this->discord->loop, $resolver); + $resolver = (new DNSFactory())->createCached($this->dnsConfig, $this->bot->loop); + $udpfac = new DatagramFactory($this->bot->loop, $resolver); $this->voiceWebsocket = $ws; @@ -430,7 +437,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $start = $data->d->t; $diff = ($end - $start) * 1000; - $this->discord->logger->debug('received heartbeat ack', ['response_time' => $diff]); + $this->bot->logger->debug('received heartbeat ack', ['response_time' => $diff]); $this->emit('ws-ping', [$diff]); $this->emit('ws-heartbeat-ack', [$data->d->t]); break; @@ -441,7 +448,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->rawKey = $data->d->secret_key; $this->secretKey = implode('', array_map(fn ($value) => pack('C', $value), $this->rawKey)); - $this->discord->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); if (! $this->reconnecting) { $this->emit('ready', [$this]); @@ -451,21 +458,29 @@ public function handleWebSocketConnection(WebSocket $ws): void } if (! $this->deaf && $this->secretKey) { - $this->client->on('message', fn (string $message) => $this->handleAudioData(new VoicePacket($message, key: $this->secretKey, log: $this->discord->logger))); + $this->client->on( + 'message', + fn (string $message) => $this->handleAudioData(new VoicePacket( + $message, + key: $this->secretKey, + log: $this->bot->logger + ))); } break; case Op::VOICE_SPEAKING: // currently connected users + $this->bot->logger->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); - $this->speakingStatus[$data->d->user_id] = $this->discord->getFactory()->create(VoiceSpeaking::class, $data->d); + $this->speakingStatus[$data->d->user_id] = $this->bot->getFactory()->create(VoiceSpeaking::class, $data->d); break; case Op::VOICE_HELLO: $this->heartbeatInterval = $data->d->heartbeat_interval; $this->sendHeartbeat(); - $this->heartbeat = $this->discord->loop->addPeriodicTimer($this->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); + $this->heartbeat = $this->bot->loop->addPeriodicTimer($this->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); break; case Op::VOICE_CLIENTS_CONNECT: + $this->bot->logger->debug('received clients connect packet', ['data' => json_decode(json_encode($data->d), true)]); # "d" contains an array with ['user_ids' => array] $this->clientsConnected = $data->d->user_ids; break; @@ -474,9 +489,10 @@ public function handleWebSocketConnection(WebSocket $ws): void break; case Op::VOICE_CLIENT_UNKNOWN_15: case Op::VOICE_CLIENT_UNKNOWN_18: - $this->discord->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + $this->bot->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); break; case Op::VOICE_CLIENT_PLATFORM: + $this->bot->logger->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); # handlePlatformPerUser # platform = 0 assumed to be Desktop break; @@ -518,7 +534,7 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->udpPort = $data->d->port; $this->ssrc = $data->d->ssrc; - $this->discord->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); $buffer = new Buffer(74); $buffer[1] = "\x01"; @@ -526,12 +542,12 @@ public function handleWebSocketConnection(WebSocket $ws): void $buffer->writeUInt32BE($this->ssrc, 4); /** @var PromiseInterface */ $udpfac->createClient("{$data->d->ip}:{$this->udpPort}")->then(function (Socket $client) use (&$ip, &$port, $buffer): void { - $this->discord->logger->debug('connected to voice UDP'); + $this->bot->logger->debug('connected to voice UDP'); $this->client = $client; - $this->discord->loop->addTimer(0.1, fn () => $this->client->send($buffer->__toString())); + $this->bot->loop->addTimer(0.1, fn () => $this->client->send($buffer->__toString())); - $this->udpHeartbeat = $this->discord->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { + $this->udpHeartbeat = $this->bot->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { $buffer = new Buffer(9); $buffer[0] = 0xC9; $buffer->writeUInt64LE($this->heartbeatSeq, 1); @@ -540,26 +556,26 @@ public function handleWebSocketConnection(WebSocket $ws): void $this->client->send($buffer->__toString()); $this->emit('udp-heartbeat', []); - $this->discord->logger->debug('sent UDP heartbeat'); + $this->bot->logger->debug('sent UDP heartbeat'); }); $client->on('error', fn ($e) => $this->emit('udp-error', [$e])); $client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); }, function (\Throwable $e): void { - $this->discord->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); + $this->bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->emit('error', [$e]); }); break; } default: - $this->discord->logger->warning('Unknown opcode.', $data); + $this->bot->logger->warning('Unknown opcode.', $data); break; } }); $ws->on('error', function ($e): void { - $this->discord->logger->error('error with voice websocket', ['e' => $e->getMessage()]); + $this->bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); $this->emit('ws-error', [$e]); }); @@ -576,7 +592,7 @@ public function handleWebSocketConnection(WebSocket $ws): void ], ); - $this->discord->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); + $this->bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); $this->send($payload); $this->sentLoginFrame = true; @@ -604,7 +620,7 @@ protected function decodeUDP($message, string &$ip, string &$port): void $ip = $unpackedMessageArray['Address']; $port = $unpackedMessageArray['Port']; - $this->discord->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + $this->bot->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); $this->send([ 'op' => Op::VOICE_SELECT_PROTO, @@ -628,7 +644,7 @@ protected function sendHeartbeat(): void 'seq_ack' => 10, ] )); - $this->discord->logger->debug('sending heartbeat'); + $this->bot->logger->debug('sending heartbeat'); $this->emit('ws-heartbeat', []); } @@ -639,7 +655,7 @@ protected function sendHeartbeat(): void */ public function handleWebSocketError(\Exception $e): void { - $this->discord->logger->error('error with voice websocket', ['e' => $e->getMessage()]); + $this->bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); $this->emit('error', [$e]); } @@ -651,43 +667,44 @@ public function handleWebSocketError(\Exception $e): void */ public function handleWebSocketClose(int $op, string $reason): void { - $this->discord->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); + $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); $this->emit('ws-close', [$op, $reason, $this]); $this->clientsConnected = []; // Cancel heartbeat timers if (null !== $this->heartbeat) { - $this->discord->loop->cancelTimer($this->heartbeat); + $this->bot->loop->cancelTimer($this->heartbeat); $this->heartbeat = null; } if (null !== $this->udpHeartbeat) { - $this->discord->loop->cancelTimer($this->udpHeartbeat); + $this->bot->loop->cancelTimer($this->udpHeartbeat); $this->udpHeartbeat = null; } // Close UDP socket. if (isset($this->client)) { - $this->discord->logger->warning('closing UDP client'); + $this->bot->logger->warning('closing UDP client'); $this->client->close(); } // Don't reconnect on a critical opcode or if closed by user. if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this->userClose) { - $this->discord->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); + $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); $this->emit('close'); - } else { - $this->discord->logger->warning('reconnecting in 2 seconds'); + return; + } - // Retry connect after 2 seconds - $this->discord->loop->addTimer(2, function (): void { - $this->reconnecting = true; - $this->sentLoginFrame = false; + $this->bot->logger->warning('reconnecting in 2 seconds'); - $this->initSockets(); - }); - } + // Retry connect after 2 seconds + $this->bot->loop->addTimer(2, function (): void { + $this->reconnecting = true; + $this->sentLoginFrame = false; + + $this->initSockets(); + }); } /** @@ -697,7 +714,7 @@ public function handleWebSocketClose(int $op, string $reason): void */ public function handleVoiceServerChange(array $data = []): void { - $this->discord->logger->debug('voice server has changed, dynamically changing servers in the background', ['data' => $data]); + $this->bot->logger->debug('voice server has changed, dynamically changing servers in the background', ['data' => $data]); $this->reconnecting = true; $this->sentLoginFrame = false; $this->pause(); @@ -705,8 +722,8 @@ public function handleVoiceServerChange(array $data = []): void $this->client->close(); $this->voiceWebsocket->close(); - $this->discord->loop->cancelTimer($this->heartbeat); - $this->discord->loop->cancelTimer($this->udpHeartbeat); + $this->bot->loop->cancelTimer($this->heartbeat); + $this->bot->loop->cancelTimer($this->udpHeartbeat); $this->data['token'] = $data['token']; // set the token if it changed $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); @@ -714,7 +731,7 @@ public function handleVoiceServerChange(array $data = []): void $this->initSockets(); $this->on('resumed', function () { - $this->discord->logger->debug('voice client resumed'); + $this->bot->logger->debug('voice client resumed'); $this->unpause(); $this->speaking = false; $this->setSpeaking(true); @@ -756,7 +773,7 @@ public function playFile(string $file, int $channels = 2): PromiseInterface } $process = $this->ffmpegEncode($file); - $process->start($this->discord->loop); + $process->start($this->bot->loop); return $this->playOggStream($process); } @@ -796,7 +813,7 @@ public function playRawStream($stream, int $channels = 2, int $audioRate = 48000 } if (is_resource($stream)) { - $stream = new Stream($stream, $this->discord->loop); + $stream = new Stream($stream, $this->bot->loop); } $process = $this->ffmpegEncode(preArgs: [ @@ -804,7 +821,7 @@ public function playRawStream($stream, int $channels = 2, int $audioRate = 48000 '-ac', $channels, '-ar', $audioRate, ]); - $process->start($this->discord->loop); + $process->start($this->bot->loop); $stream->pipe($process->stdin); return $this->playOggStream($process); @@ -849,7 +866,7 @@ public function playOggStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new Stream($stream, $this->discord->loop); + $stream = new Stream($stream, $this->bot->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -858,7 +875,7 @@ public function playOggStream($stream): PromiseInterface return $deferred->promise(); } - $this->buffer = new RealBuffer($this->discord->loop); + $this->buffer = new RealBuffer($this->bot->loop); $stream->on('data', function ($d) { $this->buffer->write($d); }); @@ -873,7 +890,7 @@ public function playOggStream($stream): PromiseInterface OggStream::fromBuffer($this->buffer)->then(function (OggStream $os) use ($deferred, &$ogg, &$loops) { $ogg = $os; $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->discord->loop->addTimer(0.5, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + $this->readOpusTimer = $this->bot->loop->addTimer(0.5, fn () => $this->readOggOpus($deferred, $ogg, $loops)); }); return $deferred->promise(); @@ -895,7 +912,7 @@ protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) // If the client is paused, delay by frame size and check again. if ($this->paused) { $this->insertSilence(); - $this->readOpusTimer = $this->discord->loop->addTimer($this->frameSize / 1000, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + $this->readOpusTimer = $this->bot->loop->addTimer($this->frameSize / 1000, fn () => $this->readOggOpus($deferred, $ogg, $loops)); return; } @@ -926,7 +943,7 @@ protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) $nextTime = $this->startTime + (20.0 / 1000.0) * $loops; $delay = $nextTime - microtime(true); - $this->readOpusTimer = $this->discord->loop->addTimer($delay, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + $this->readOpusTimer = $this->bot->loop->addTimer($delay, fn () => $this->readOggOpus($deferred, $ogg, $loops)); }, function ($e) use ($deferred) { $this->reset(); $deferred->resolve(null); @@ -973,7 +990,7 @@ public function playDCAStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new Stream($stream, $this->discord->loop); + $stream = new Stream($stream, $this->bot->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -982,7 +999,7 @@ public function playDCAStream($stream): PromiseInterface return $deferred->promise(); } - $this->buffer = new RealBuffer($this->discord->loop); + $this->buffer = new RealBuffer($this->bot->loop); $stream->on('data', function ($d) { $this->buffer->write($d); }); @@ -1008,7 +1025,7 @@ public function playDCAStream($stream): PromiseInterface } $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->discord->loop->addTimer(0.5, fn () => $this->readDCAOpus($deferred)); + $this->readOpusTimer = $this->bot->loop->addTimer(0.5, fn () => $this->readDCAOpus($deferred)); }); return $deferred->promise(); @@ -1028,7 +1045,7 @@ protected function readDCAOpus(Deferred $deferred): void // If the client is paused, delay by frame size and check again. if ($this->paused) { $this->insertSilence(); - $this->readOpusTimer = $this->discord->loop->addTimer($this->frameSize / 1000, fn () => $this->readDCAOpus($deferred)); + $this->readOpusTimer = $this->bot->loop->addTimer($this->frameSize / 1000, fn () => $this->readDCAOpus($deferred)); return; } @@ -1052,7 +1069,7 @@ protected function readDCAOpus(Deferred $deferred): void $this->timestamp = 0; } - $this->readOpusTimer = $this->discord->loop->addTimer(($this->frameSize - 1) / 1000, fn () => $this->readDCAOpus($deferred)); + $this->readOpusTimer = $this->bot->loop->addTimer(($this->frameSize - 1) / 1000, fn () => $this->readDCAOpus($deferred)); }, function () use ($deferred) { $this->reset(); $deferred->resolve(null); @@ -1065,7 +1082,7 @@ protected function readDCAOpus(Deferred $deferred): void protected function reset(): void { if ($this->readOpusTimer) { - $this->discord->loop->cancelTimer($this->readOpusTimer); + $this->bot->loop->cancelTimer($this->readOpusTimer); $this->readOpusTimer = null; } @@ -1088,7 +1105,7 @@ protected function sendBuffer(string $data): void return; } - $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secretKey); + $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secretKey, log: $this->bot->logger); $this->client->send((string) $packet); $this->streamTime = (int) microtime(true); @@ -1235,7 +1252,7 @@ protected function send($data): void protected function mainSend($data): void { $json = json_encode($data); - $this->mainWebsocket->send($json); + $this->bot->ws->send($json); } /** @@ -1361,12 +1378,12 @@ public function close(): void $this->heartbeatInterval = null; if (null !== $this->heartbeat) { - $this->discord->loop->cancelTimer($this->heartbeat); + $this->bot->loop->cancelTimer($this->heartbeat); $this->heartbeat = null; } if (null !== $this->udpHeartbeat) { - $this->discord->loop->cancelTimer($this->udpHeartbeat); + $this->bot->loop->cancelTimer($this->udpHeartbeat); $this->udpHeartbeat = null; } @@ -1450,8 +1467,11 @@ protected function removeDecoder($ss): void } $decoder->close(); - unset($this->voiceDecoders[$ss->ssrc]); - unset($this->speakingStatus[$ss->ssrc]); + unset( + $this->voiceDecoders[$ss->ssrc], + $this->speakingStatus[$ss->ssrc], + $this->receiveStreams[$ss->ssrc] + ); } /** @@ -1506,7 +1526,7 @@ protected function handleAudioData(VoicePacket $voicePacket): void return; } // There's no message or the message threw an error inside the decrypt function - $this->discord->logger->warning('No audio data.', ['voicePacket' => $voicePacket]); + $this->bot->logger->warning('No audio data.', ['voicePacket' => $voicePacket]); return; } @@ -1517,7 +1537,7 @@ protected function handleAudioData(VoicePacket $voicePacket): void if (null === $ss) { // for some reason we don't have a speaking status - $this->discord->logger->warning('Unknown SSRC.', ['ssrc' => $voicePacket->getSSRC(), 't' => $voicePacket->getTimestamp()]); + $this->bot->logger->warning('Unknown SSRC.', ['ssrc' => $voicePacket->getSSRC(), 't' => $voicePacket->getTimestamp()]); return; } @@ -1536,19 +1556,18 @@ protected function handleAudioData(VoicePacket $voicePacket): void } $this->createDecoder($ss); - $decoder = $this->voiceDecoders[$voicePacket->getSSRC()] ?? null; } - $audioData = $voicePacket->getAudioData(); + //$audioData = $decoder->stdin->write($voicePacket->getAudioData()); - $buff = new Buffer(strlen($audioData) + 2); + /* $buff = new Buffer(strlen($audioData) + 2); $buff->write(pack('s', strlen($audioData)), 0); $buff->write($audioData, 2); $stdinHandle = fopen($this->tempFiles['stdin'], 'a'); // Use append mode fwrite($stdinHandle, (string) $buff); fflush($stdinHandle); // Make sure the data is written immediately - fclose($stdinHandle); + fclose($stdinHandle); */ } /** @@ -1559,9 +1578,27 @@ protected function handleAudioData(VoicePacket $voicePacket): void protected function createDecoder($ss): void { $decoder = $this->ffmpegDecode(); - $decoder->start($this->discord->loop); + $decoder->start($this->bot->loop); + + $decoder->stdout->on('data', function ($data) use ($ss) { + if (empty($data)) { + return; // no data to process + } - // Handle stdout + $this->receiveStreams[$ss->ssrc]->writePCM($data); + $this->receiveStreams[$ss->ssrc]->writeOpus($data); + }); + + $decoder->stderr->on('data', function ($data) use ($ss) { + if (empty($data)) { + return; // no data to process + } + + $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); + $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); + }); + + /* // Handle stdout $stdoutHandle = fopen($this->tempFiles['stdout'], 'r'); $this->discord->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { $data = fread($stdoutHandle, 8192); @@ -1578,7 +1615,7 @@ protected function createDecoder($ss): void $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); } - }); + }); */ // Store the decoder $this->voiceDecoders[$ss->ssrc] = $decoder; @@ -1600,14 +1637,14 @@ protected function monitorProcessExit(Process $process, $ss): void // $pid = $process->getPid(); // Check every second if the process is still running - $this->monitorProcessTimer = $this->discord->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { + $this->monitorProcessTimer = $this->bot->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { // Check if the process is still running if (!$process->isRunning()) { // Get the exit code $exitCode = $process->getExitCode(); // Clean up the timer - $this->discord->loop->cancelTimer($this->monitorProcessTimer); + $this->bot->loop->cancelTimer($this->monitorProcessTimer); // If exit code indicates an error, emit event and recreate decoder if ($exitCode > 0) { @@ -1634,7 +1671,7 @@ protected function cleanupTempFiles(): void protected function handleDavePrepareTransition($data) { - $this->discord->logger->debug('DAVE Prepare Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition $this->send(VoicePayload::new( Op::VOICE_DAVE_TRANSITION_READY, @@ -1646,20 +1683,20 @@ protected function handleDavePrepareTransition($data) protected function handleDaveExecuteTransition($data) { - $this->discord->logger->debug('DAVE Execute Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE Execute Transition', ['data' => $data]); // Execute the transition // Update local state to reflect the new protocol context } protected function handleDaveTransitionReady($data) { - $this->discord->logger->debug('DAVE Transition Ready', ['data' => $data]); + $this->bot->logger->debug('DAVE Transition Ready', ['data' => $data]); // Handle transition ready state } protected function handleDavePrepareEpoch($data) { - $this->discord->logger->debug('DAVE Prepare Epoch', ['data' => $data]); + $this->bot->logger->debug('DAVE Prepare Epoch', ['data' => $data]); // Prepare local MLS group with parameters appropriate for the DAVE protocol version $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, @@ -1672,19 +1709,19 @@ protected function handleDavePrepareEpoch($data) protected function handleDaveMlsExternalSender($data) { - $this->discord->logger->debug('DAVE MLS External Sender', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); // Handle external sender public key and credential } protected function handleDaveMlsKeyPackage($data) { - $this->discord->logger->debug('DAVE MLS Key Package', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); // Handle MLS key package } protected function handleDaveMlsProposals($data) { - $this->discord->logger->debug('DAVE MLS Proposals', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Proposals', ['data' => $data]); // Handle MLS proposals $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_COMMIT_WELCOME, @@ -1697,25 +1734,25 @@ protected function handleDaveMlsProposals($data) protected function handleDaveMlsCommitWelcome($data) { - $this->discord->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); // Handle MLS commit and welcome messages } protected function handleDaveMlsAnnounceCommitTransition($data) { // Handle MLS announce commit transition - $this->discord->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); } protected function handleDaveMlsWelcome($data) { // Handle MLS welcome message - $this->discord->logger->debug('DAVE MLS Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Welcome', ['data' => $data]); } protected function handleDaveMlsInvalidCommitWelcome($data) { - $this->discord->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); // Handle invalid commit or welcome message // Reset local group state and generate a new key package $this->send(VoicePayload::new( @@ -1910,18 +1947,13 @@ public function ffmpegDecode(int $channels = 2, ?int $frameSize = null): Process // Store temp file paths for later cleanup $this->tempFiles = [ - 'stdin' => $stdinFile, - 'stdout' => $stdoutFile, - 'stderr' => $stderrFile, + 'stdin' => 'php://temp', + 'stdout' => 'php://temp', + 'stderr' => 'php://temp', ]; return new Process( "{$this->ffmpeg} {$flags}", - fds: [ - ['file', $stdinFile, 'w'], - ['file', $stdoutFile, 'w+'], - ['file', $stderrFile, 'w+'], - ] ); } From 33224e23378bcdf4c235b4d266a95be9406737e7 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 17 Jun 2025 10:20:10 +0100 Subject: [PATCH 090/121] Updates VoiceClient creation to match previous commit --- src/Discord/Voice/VoiceManager.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index 356160dd9..d61ded753 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -112,7 +112,7 @@ protected function serverUpdate($state, Channel $channel, Discord $discord, Defe 'endpoint' => $state->endpoint ]); - $client = new VoiceClient($discord, $this->bot->getWs(), $channel, $data); + $client = new VoiceClient($discord, $channel, $data); $client->once('ready', function () use ($client, $deferred, $channel) { $this->bot->getLogger()->info('voice client is ready'); From 5f96ff971853451027210feee2eceb821d807bed Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 17 Jun 2025 15:55:09 +0100 Subject: [PATCH 091/121] Updates some comments Adds a early return Adds a new exception --- src/Discord/Discord.php | 33 +++++++++++++------ .../RequiredExtensionNotLoadedException.php | 30 +++++++++++++++++ 2 files changed, 53 insertions(+), 10 deletions(-) create mode 100644 src/Discord/Exceptions/Runtime/RequiredExtensionNotLoadedException.php diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index e880f9e82..d2a0d7d8f 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -14,6 +14,7 @@ namespace Discord; use Discord\Exceptions\IntentException; +use Discord\Exceptions\Runtime\RequiredExtensionNotLoadedException; use Discord\Factory\Factory; use Discord\Helpers\BigInt; use Discord\Helpers\CacheConfig; @@ -44,6 +45,8 @@ use Discord\WebSockets\Op; use Discord\WebSockets\Payload; use Evenement\EventEmitterTrait; +use function React\Async\coroutine; +use function React\Promise\all; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Logger as Monolog; @@ -55,13 +58,11 @@ use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; use React\Promise\Deferred; + use React\Promise\PromiseInterface; use React\Socket\Connector as SocketConnector; use Symfony\Component\OptionsResolver\OptionsResolver; -use function React\Async\coroutine; -use function React\Promise\all; - /** * The Discord client class. * @@ -366,7 +367,7 @@ public function __construct(array $options = []) { // x86 need gmp extension for big integer operation if (PHP_INT_SIZE === 4 && ! BigInt::init()) { - throw new \RuntimeException('ext-gmp is not loaded, it is required for 32-bits (x86) PHP.'); + throw new RequiredExtensionNotLoadedException(); } $options = $this->resolveOptions($options); @@ -376,7 +377,8 @@ public function __construct(array $options = []) $this->loop = $options['loop']; $this->logger = $options['logger']; - if (!in_array(php_sapi_name(), ['cli', 'micro'])) { + if (! in_array(php_sapi_name(), ['cli', 'micro'])) { + // @todo: throw an exception instead? $this->logger->critical('DiscordPHP will not run on a webserver. Please use PHP CLI to run a DiscordPHP bot.'); } @@ -607,6 +609,10 @@ protected function handleVoiceStateUpdate(Payload $data): void /** * Handles WebSocket connections received by the client. * + * @uses \Discord\Discord::handleWsMessage + * @uses \Discord\Discord::handleWsClose + * @uses \Discord\Discord::handleWsError + * * @param WebSocket $ws WebSocket client. */ public function handleWsConnection(WebSocket $ws): void @@ -776,6 +782,12 @@ public function handleWsConnectionFailed(\Throwable $e): void /** * Handles dispatch events received by the WebSocket. * + * @uses \Discord\Discord::handleVoiceStateUpdate + * @uses \Discord\Discord::handleVoiceServerUpdate + * @uses \Discord\Discord::handleResume + * @uses \Discord\Discord::handleReady + * @uses \Discord\Discord::handleGuildMembersChunk + * * @param object $data Packet data. */ protected function handleDispatch(object $data): void @@ -800,7 +812,6 @@ protected function handleDispatch(object $data): void $this->{$handlers[$data->t]}(Payload::new($data->op, $data->d, $data->s, $data->t)); } - return; } @@ -846,11 +857,13 @@ protected function handleDispatch(object $data): void $promise = coroutine([$handler, 'handle'], $data->d); $promise->then([$deferred, 'resolve'], [$deferred, 'reject']); }; - } else { - /** @var PromiseInterface */ - $promise = coroutine([$handler, 'handle'], $data->d); - $promise->then([$deferred, 'resolve'], [$deferred, 'reject']); + + return; } + + /** @var PromiseInterface */ + $promise = coroutine([$handler, 'handle'], $data->d); + $promise->then([$deferred, 'resolve'], [$deferred, 'reject']); } /** diff --git a/src/Discord/Exceptions/Runtime/RequiredExtensionNotLoadedException.php b/src/Discord/Exceptions/Runtime/RequiredExtensionNotLoadedException.php new file mode 100644 index 000000000..47d8cc32e --- /dev/null +++ b/src/Discord/Exceptions/Runtime/RequiredExtensionNotLoadedException.php @@ -0,0 +1,30 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Runtime; + +use RuntimeException; + +/** + * Thrown when attachment size exceeds the maximum allowed size. + * + * @since 10.10.0 + */ +class RequiredExtensionNotLoadedException extends RuntimeException +{ + /** + * Create a new required extension not loaded exception. + */ + public function __construct() + { + parent::__construct('The ext-gmp extension is not loaded, it is required for 32-bits (x86) PHP.'); + } +} From bd709e40f399732eb667a71c85a1061850d9e28c Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 17 Jun 2025 15:55:24 +0100 Subject: [PATCH 092/121] Adds new bool checks on Channel --- src/Discord/Parts/Channel/Channel.php | 40 +++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/Discord/Parts/Channel/Channel.php b/src/Discord/Parts/Channel/Channel.php index c76c5dbbf..d209eb06f 100644 --- a/src/Discord/Parts/Channel/Channel.php +++ b/src/Discord/Parts/Channel/Channel.php @@ -1722,4 +1722,44 @@ public function __toString(): string { return "<#{$this->id}>"; } + + /** + * Checks if the channel can be connected to. + * + * @return bool + */ + public function canConnect(): bool + { + return $this->isVoiceBased() && $this->getBotPermissions()->connect; + } + + /** + * Alias of canConnect. + * + * @return bool + */ + public function canJoin(): bool + { + return $this->canConnect(); + } + + /** + * Checks if the channel can be spoken in. + * + * @return bool + */ + public function canSpeak(): bool + { + return $this->isVoiceBased() && $this->getBotPermissions()->speak; + } + + /** + * Checks if the bot is a priority speaker in the channel. + * + * @return bool + */ + public function isPrioritySpeaker(): bool + { + return $this->isVoiceBased() && $this->getBotPermissions()->priority_speaker; + } } From 22f741cea6f875ac177b284378558ffeff02402e Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Tue, 17 Jun 2025 15:55:37 +0100 Subject: [PATCH 093/121] Adds new classes for Process Ffmpeg --- src/Discord/Voice/Processes/Ffmpeg.php | 121 ++++++++++++++++++ .../Voice/Processes/ProcessAbstract.php | 37 ++++++ 2 files changed, 158 insertions(+) create mode 100644 src/Discord/Voice/Processes/Ffmpeg.php create mode 100644 src/Discord/Voice/Processes/ProcessAbstract.php diff --git a/src/Discord/Voice/Processes/Ffmpeg.php b/src/Discord/Voice/Processes/Ffmpeg.php new file mode 100644 index 000000000..6cb23dee2 --- /dev/null +++ b/src/Discord/Voice/Processes/Ffmpeg.php @@ -0,0 +1,121 @@ +checkForFFmpeg()) { + throw new FFmpegNotFoundException('FFmpeg binary not found.'); + } + } + + public static function __callStatic(string $name, array $arguments) + { + if (method_exists(self::class, $name) && in_array($name, ['encode', 'decode'])) { + if (! self::checkForFFmpeg()) { + throw new FFmpegNotFoundException('FFmpeg binary not found.'); + } + + return self::$name(...$arguments); + } + + throw new \BadMethodCallException("Method {$name} does not exist in " . __CLASS__); + } + + public static function checkForFFmpeg(): bool + { + $binaries = [ + 'ffmpeg', + ]; + + foreach ($binaries as $binary) { + $output = self::checkForExecutable($binary); + + if (null !== $output) { + self::$exec = $output; + + return true; + } + } + + return false; + } + + public static function encode( + ?string $filename = null, + int|float $volume = 0, + int $bitrate = 128000, + ?array $preArgs = null + ): Process + { + $flags = [ + '-i', $filename ?? 'pipe:0', + '-map_metadata', '-1', + '-f', 'opus', + '-c:a', 'libopus', + '-ar', '48000', + '-af', "volume={$volume}dB", + '-ac', '2', + '-b:a', $bitrate, + '-loglevel', 'warning', + 'pipe:1', + ]; + + if (null !== $preArgs) { + $flags = array_merge($preArgs, $flags); + } + + $flags = implode(' ', $flags); + $cmd = self::$exec . " {$flags}"; + + return new Process( + $cmd, + fds: [ + ['socket'], + ['socket'], + ['socket'], + ] + ); + } + + public static function decode( + ?string $filename = null, + int|float $volume = 0, + int $bitrate = 128000, + int $channels = 2, + ?int $frameSize = null, + ?array $preArgs = null, + ): Process + { + if (null === $frameSize) { + $frameSize = round(20 * 48); + } + + $flags = [ + '-ac:opus', $channels, // Channels + '-ab', round($bitrate / 1000), // Bitrate + '-as', $frameSize, // Frame Size + '-ar', '48000', // Audio Rate + '-mode', 'decode', // Decode mode + ]; + + $flags = implode(' ', $flags); + + return new Process( + self::$exec . " {$flags}", + fds: [ + ['socket'], + ['socket'], + ['socket'], + ] + ); + } +} diff --git a/src/Discord/Voice/Processes/ProcessAbstract.php b/src/Discord/Voice/Processes/ProcessAbstract.php new file mode 100644 index 000000000..15bc7ec22 --- /dev/null +++ b/src/Discord/Voice/Processes/ProcessAbstract.php @@ -0,0 +1,37 @@ + Date: Tue, 17 Jun 2025 15:56:18 +0100 Subject: [PATCH 094/121] Updates some flow on how the VoiceClient is created & the VoiceManager calls it --- src/Discord/Voice/VoiceClient.php | 188 ++++++++++------------------- src/Discord/Voice/VoiceManager.php | 59 ++++----- 2 files changed, 89 insertions(+), 158 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 70c50892a..2bbcca8a5 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -27,6 +27,7 @@ use Discord\Parts\Channel\Channel; use Discord\Parts\EventData\VoiceSpeaking; use Discord\Voice\Client\User; +use Discord\Voice\Processes\Ffmpeg; use Discord\Voice\ReceiveStream; use Discord\Voice\VoicePacket; use Discord\WebSockets\Op; @@ -373,12 +374,16 @@ public function __construct( protected array $data, protected bool $deaf = false, protected bool $mute = false, + protected ?Deferred $deferred = null, + protected ?VoiceManager &$manager = null, ) { $this->deaf = $this->data['deaf'] ?? false; $this->mute = $this->data['mute'] ?? false; $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); $this->speakingStatus = Collection::for(VoiceSpeaking::class, 'ssrc'); $this->dnsConfig = $data['dnsConfig']; + + $this->boot(); } /** @@ -389,7 +394,7 @@ public function __construct( public function start(): bool { if ( - ! $this->checkForFFmpeg() || + ! Ffmpeg::checkForFFmpeg() || ! $this->checkForLibsodium() ) { return false; @@ -772,7 +777,7 @@ public function playFile(string $file, int $channels = 2): PromiseInterface return $deferred->promise(); } - $process = $this->ffmpegEncode($file); + $process = Ffmpeg::encode($file, volume: $this->getDbVolume()); $process->start($this->bot->loop); return $this->playOggStream($process); @@ -816,7 +821,7 @@ public function playRawStream($stream, int $channels = 2, int $audioRate = 48000 $stream = new Stream($stream, $this->bot->loop); } - $process = $this->ffmpegEncode(preArgs: [ + $process = Ffmpeg::encode(volume: $this->getDbVolume(), preArgs: [ '-f', 's16le', '-ac', $channels, '-ar', $audioRate, @@ -1577,7 +1582,7 @@ protected function handleAudioData(VoicePacket $voicePacket): void */ protected function createDecoder($ss): void { - $decoder = $this->ffmpegDecode(); + $decoder = Ffmpeg::decode(); $decoder->start($this->bot->loop); $decoder->stdout->on('data', function ($data) use ($ss) { @@ -1788,32 +1793,6 @@ public function isReady(): bool return $this->ready; } - /** - * Checks if FFmpeg is installed. - * - * @return bool Whether FFmpeg is installed or not. - */ - protected function checkForFFmpeg(): bool - { - $binaries = [ - 'ffmpeg', - ]; - - foreach ($binaries as $binary) { - $output = $this->checkForExecutable($binary); - - if (null !== $output) { - $this->ffmpeg = $output; - - return true; - } - } - - $this->emit('error', [new FFmpegNotFoundException('No FFmpeg binary was found.')]); - - return false; - } - /** * Checks if libsodium-php is installed. * @@ -1830,71 +1809,13 @@ protected function checkForLibsodium(): bool return true; } - /** - * Checks if an executable exists on the system. - * - * @param string $executable - * @return string|null - */ - protected static function checkForExecutable(string $executable): ?string + public function getDbVolume(): float|int { - $which = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? 'where' : 'command -v'; - $executable = rtrim((string) explode(PHP_EOL, shell_exec("{$which} {$executable}"))[0]); - - return is_executable($executable) ? $executable : null; - } - - /** - * Creates a process that will run FFmpeg and encode `$filename` into Ogg - * Opus format. - * - * If `$filename` is null, the process will expect some sort of audio data - * to be piped in via stdin. It is highly recommended to set `$preArgs` to - * contain the format of the piped data when using a pipe as an input. You - * may also want to provide some arguments to FFmpeg via `$preArgs`, which - * will be appended to the FFmpeg command _before_ setting the input - * arguments. - * - * @param ?string $filename Path to file to be converted into Ogg Opus, or - * null for pipe via stdin. - * @param ?array $preArgs A list of arguments to be appended before the - * input filename. - * - * @return Process A ReactPHP child process. - */ - public function ffmpegEncode(?string $filename = null, ?array $preArgs = null): Process - { - $dB = match($this->volume) { + return match($this->volume) { 0 => -100, 100 => 0, default => -40 + ($this->volume / 100) * 40, }; - - $flags = [ - '-i', $filename ?? 'pipe:0', - '-map_metadata', '-1', - '-f', 'opus', - '-c:a', 'libopus', - '-ar', '48000', - '-af', 'volume=' . $dB . 'dB', - '-ac', '2', - '-b:a', $this->bitrate, - '-loglevel', 'warning', - 'pipe:1', - ]; - - if (null !== $preArgs) { - $flags = array_merge($preArgs, $flags); - } - - $flags = implode(' ', $flags); - $cmd = "{$this->ffmpeg} {$flags}"; - - return new Process($cmd, null, null, [ - ['socket'], - ['socket'], - ['socket'], - ]); } /** @@ -1923,40 +1844,6 @@ public function dcaDecode(int $channels = 2, ?int $frameSize = null): Process return new Process("{$this->dca} {$flags}"); } - public function ffmpegDecode(int $channels = 2, ?int $frameSize = null): Process - { - if (null === $frameSize) { - $frameSize = round($this->frameSize * 48); - } - - $flags = [ - '-ac:opus', $channels, // Channels - '-ab', round($this->bitrate / 1000), // Bitrate - '-as', $frameSize, // Frame Size - '-ar', '48000', // Audio Rate - '-mode', 'decode', // Decode mode - ]; - - $flags = implode(' ', $flags); - - // Create temporary files for stdin, stdout, and stderr - $tempDir = sys_get_temp_dir(); - $stdinFile = tempnam($tempDir, 'discord_ffmpeg_stdin_' . $this->ssrc); - $stdoutFile = tempnam($tempDir, 'discord_ffmpeg_stdout_' . $this->ssrc); - $stderrFile = tempnam($tempDir, 'discord_ffmpeg_stderr_' . $this->ssrc); - - // Store temp file paths for later cleanup - $this->tempFiles = [ - 'stdin' => 'php://temp', - 'stdout' => 'php://temp', - 'stderr' => 'php://temp', - ]; - - return new Process( - "{$this->ffmpeg} {$flags}", - ); - } - /** * Returns the connected channel. * @@ -1978,4 +1865,57 @@ public function insertSilence(): void $this->sendBuffer(self::SILENCE_FRAME); } } + + /** + * Creates a new voice client instance statically + * + * @param \Discord\Discord $bot + * @param \Discord\Parts\Channel\Channel $channel + * @param array $data + * @param bool $deaf + * @param bool $mute + * @param mixed $deferred + * @param mixed $manager + * @param array $ + * @return \Discord\Voice\VoiceClient + */ + public static function make( + Discord $bot, + Channel $channel, + array $data, + bool $deaf = false, + bool $mute = false, + ?Deferred $deferred = null, + ?VoiceManager &$manager = null, + ): self + { + return new static(...func_get_args()); + } + + /** + * Boots the voice client and sets up event listeners. + * + * @return void + */ + public function boot(): void + { + $this->once('ready', function () { + $this->bot->getLogger()->info('voice client is ready'); + $this->manager->clients[$this->channel->guild_id] = $this; + + $this->setBitrate($this->channel->bitrate); + + $this->bot->getLogger()->info('set voice client bitrate', ['bitrate' => $this->channel->bitrate]); + $this->deferred->resolve($this); + }) + ->once('error', function ($e) { + $this->bot->getLogger()->error('error initializing voice client', ['e' => $e->getMessage()]); + $this->deferred->reject($e); + }) + ->once('close', function () { + $this->bot->getLogger()->warning('voice client closed'); + unset($this->manager->clients[$this->channel->guild_id]); + }) + ->start(); + } } diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index d61ded753..2a11fadb2 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -22,7 +22,7 @@ final class VoiceManager /** * @param Discord $bot - * @param array $clients + * @param array $clients */ public function __construct( protected Discord $bot, @@ -30,6 +30,15 @@ public function __construct( ) { } + /** + * Handles the creation of a new voice client and joins the specified channel. + * + * @param \Discord\Parts\Channel\Channel $channel + * @param \Discord\Discord $discord + * @param bool $mute + * @param bool $deaf + * @return \React\Promise\PromiseInterface + */ public function createClientAndJoinChannel( Channel $channel, Discord $discord, @@ -45,6 +54,18 @@ public function createClientAndJoinChannel( return $deferred->promise(); } + if (! $channel->canJoin()) { + $deferred->reject(new \RuntimeException('The bot must have proper permissions to join this channel.')); + + return $deferred->promise(); + } + + if (! $channel->canSpeak() && ! $mute) { + $deferred->reject(new \RuntimeException('The bot must have permission to speak in this channel.')); + + return $deferred->promise(); + } + if (isset($this->clients[$channel->guild_id])) { $deferred->reject(new \RuntimeException('You cannot join more than one voice channel per guild.')); @@ -59,6 +80,7 @@ public function createClientAndJoinChannel( ]; $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); + // Creates Voice Client and waits for the voice server update. $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, Discord $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); $discord->send(VoicePayload::new( @@ -83,7 +105,7 @@ public function getClient(string $guildId): ?VoiceClient return $this->clients[$guildId]; } - protected function stateUpdate($state, $channel): void + protected function stateUpdate($state, Channel $channel): void { if ($state->guild_id != $channel->guild_id) { return; // This voice state update isn't for our guild. @@ -112,38 +134,7 @@ protected function serverUpdate($state, Channel $channel, Discord $discord, Defe 'endpoint' => $state->endpoint ]); - $client = new VoiceClient($discord, $channel, $data); - - $client->once('ready', function () use ($client, $deferred, $channel) { - $this->bot->getLogger()->info('voice client is ready'); - $this->clients[$channel->guild_id] = $client; - - $client->setBitrate($channel->bitrate); - - $this->bot->getLogger()->info('set voice client bitrate', ['bitrate' => $channel->bitrate]); - $deferred->resolve($client); - }) - ->once('error', function ($e) use ($deferred) { - $this->bot->getLogger()->error('error initializing voice client', ['e' => $e->getMessage()]); - $deferred->reject($e); - }) - ->once('close', function () use ($channel) { - $this->bot->getLogger()->warning('voice client closed'); - unset($this->clients[$channel->guild_id]); - }) - ->start(); + VoiceClient::make($discord, $channel, $data, deferred: $deferred, manager: $this); } - protected function sendStateUpdate(Channel $channel, bool $mute = false, bool $deaf = true): void - { - $this->bot->ws->send(json_encode([ - 'op' => Op::OP_VOICE_STATE_UPDATE, - 'd' => [ - 'guild_id' => $channel->guild_id, - 'channel_id' => $channel->id, - 'self_mute' => $mute, - 'self_deaf' => $deaf, - ], - ])); - } } From 69298c57544ec3778b305d83fcad4af189c661da Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 18 Jun 2025 17:43:44 +0100 Subject: [PATCH 095/121] Adds missing checks Fixes issue on deprecated assigning null on non nullable variable --- src/Discord/Helpers/Buffer.php | 2 +- src/Discord/Voice/Processes/Ffmpeg.php | 4 ++++ src/Discord/Voice/RecieveStream.php | 2 ++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Discord/Helpers/Buffer.php b/src/Discord/Helpers/Buffer.php index 535c2c24d..e452bf6ff 100644 --- a/src/Discord/Helpers/Buffer.php +++ b/src/Discord/Helpers/Buffer.php @@ -54,7 +54,7 @@ class Buffer extends EventEmitter implements WritableStreamInterface */ protected $loop; - public function __construct(LoopInterface $loop = null) + public function __construct(?LoopInterface $loop = null) { $this->loop = $loop; } diff --git a/src/Discord/Voice/Processes/Ffmpeg.php b/src/Discord/Voice/Processes/Ffmpeg.php index 6cb23dee2..ce21de9b4 100644 --- a/src/Discord/Voice/Processes/Ffmpeg.php +++ b/src/Discord/Voice/Processes/Ffmpeg.php @@ -107,6 +107,10 @@ public static function decode( '-mode', 'decode', // Decode mode ]; + if (null !== $preArgs) { + $flags = array_merge($preArgs, $flags); + } + $flags = implode(' ', $flags); return new Process( diff --git a/src/Discord/Voice/RecieveStream.php b/src/Discord/Voice/RecieveStream.php index 7a61bdf31..be2f6cd60 100644 --- a/src/Discord/Voice/RecieveStream.php +++ b/src/Discord/Voice/RecieveStream.php @@ -141,6 +141,7 @@ public function isWritable() public function write($data) { $this->writePCM($data); + $this->writeOpus($data); } /** @@ -217,6 +218,7 @@ public function resume() public function pipe(WritableStreamInterface $dest, array $options = []) { $this->pipePCM($dest, $options); + $this->pipeOpus($dest, $options); } /** From a55c071b0159220bf4438cbd998b22d0c92ea092 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 18 Jun 2025 17:45:03 +0100 Subject: [PATCH 096/121] Creates Packet class, moved into Client folder inside Voice Adds User class inside Voice/Client to handle voice client's users' decoding, streaming audio & speaking status Adds WS class (statically) to call every other function statically --- src/Discord/Parts/Voice/UserConnected.php | 26 + src/Discord/Voice/Client/HeaderValuesEnum.php | 24 + .../{VoicePacket.php => Client/Packet.php} | 112 ++-- src/Discord/Voice/Client/User.php | 22 + src/Discord/Voice/Client/Ws.php | 508 ++++++++++++++++ src/Discord/Voice/Processes/Dca.php | 91 +++ src/Discord/Voice/VoiceClient.php | 570 ++---------------- src/Discord/WebSockets/OpEnum.php | 342 +++++++++++ 8 files changed, 1120 insertions(+), 575 deletions(-) create mode 100644 src/Discord/Parts/Voice/UserConnected.php create mode 100644 src/Discord/Voice/Client/HeaderValuesEnum.php rename src/Discord/Voice/{VoicePacket.php => Client/Packet.php} (80%) create mode 100644 src/Discord/Voice/Client/User.php create mode 100644 src/Discord/Voice/Client/Ws.php create mode 100644 src/Discord/Voice/Processes/Dca.php create mode 100644 src/Discord/WebSockets/OpEnum.php diff --git a/src/Discord/Parts/Voice/UserConnected.php b/src/Discord/Parts/Voice/UserConnected.php new file mode 100644 index 000000000..54a1aef3d --- /dev/null +++ b/src/Discord/Parts/Voice/UserConnected.php @@ -0,0 +1,26 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Parts\Voice; + +use Discord\Parts\Part; + +class UserConnected extends Part +{ + /** + * {@inheritDoc} + */ + protected $fillable = [ + 'user_id', + ]; +} diff --git a/src/Discord/Voice/Client/HeaderValuesEnum.php b/src/Discord/Voice/Client/HeaderValuesEnum.php new file mode 100644 index 000000000..cbd20204e --- /dev/null +++ b/src/Discord/Voice/Client/HeaderValuesEnum.php @@ -0,0 +1,24 @@ +unpack($data) - ->decrypt(); + public function __construct( + ?string $data = null, + public ?int $ssrc = null, + public ?int $seq = null, + public ?int $timestamp = null, + bool $decrypt = true, + protected ?string $key = null, + protected ?Logger $log = null + ) { + $this->unpack($data); + + if ($decrypt) { + $this->decrypt(); + } } /** @@ -167,6 +132,10 @@ public function __construct(?string $data = null, ?int $ssrc = null, ?int $seq = * @see https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes-voice-packet-structure * @see https://www.php.net/manual/en/function.unpack.php * @see https://www.php.net/manual/en/function.pack.php For the formats + * + * @param string $message The voice message to unpack. + * + * @return self The unpacked voice packet. */ public function unpack(string $message): self { @@ -179,8 +148,8 @@ public function unpack(string $message): self $byteData = substr( $message, - self::RTP_HEADER_BYTE_LENGTH, - strlen($message) - self::AUTH_TAG_LENGTH - self::NONCE_LENGTH + HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value, + strlen($message) - HeaderValuesEnum::AUTH_TAG_LENGTH->value - HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value ); $unpackedMessage = unpack('Cfirst/Csecond/nseq/Ntimestamp/Nssrc', $byteHeader); @@ -231,16 +200,16 @@ public function decrypt(?string $message = null): string|false|null } // 3. Extract the nonce - $nonce = substr($message, $len - self::NONCE_BYTE_LENGTH, self::NONCE_BYTE_LENGTH); + $nonce = substr($message, $len - HeaderValuesEnum::TIMESTAMP_OR_NONCE_INDEX->value, HeaderValuesEnum::TIMESTAMP_OR_NONCE_INDEX->value); // 4. Pad the nonce to 12 bytes $nonceBuffer = str_pad($nonce, SODIUM_CRYPTO_AEAD_AES256GCM_NPUBBYTES, "\0", STR_PAD_RIGHT); // 5. Extract the ciphertext and auth tag // The message: [header][ciphertext][auth tag][nonce] // The size of the ciphertext is: total - headerSize - 16 (auth tag) - 4 (nonce) - $encryptedLength = $len - $this->headerSize - self::AUTH_TAG_LENGTH - self::NONCE_BYTE_LENGTH; + $encryptedLength = $len - $this->headerSize - HeaderValuesEnum::AUTH_TAG_LENGTH->value - HeaderValuesEnum::TIMESTAMP_OR_NONCE_INDEX->value; $cipherText = substr($message, $this->headerSize, $encryptedLength); - $authTag = substr($message, $this->headerSize + $encryptedLength, self::AUTH_TAG_LENGTH); + $authTag = substr($message, $this->headerSize + $encryptedLength, HeaderValuesEnum::AUTH_TAG_LENGTH->value); // Concatenate the ciphertext and the auth tag $combined = "$cipherText$authTag"; @@ -322,12 +291,12 @@ protected function initBufferEncryption(string $data, string $key): void */ protected function buildHeader(): Buffer { - $header = new Buffer(self::RTP_HEADER_BYTE_LENGTH); - $header[self::RTP_VERSION_PAD_EXTEND_INDEX] = pack(FormatPackEnum::C->value, self::RTP_VERSION_PAD_EXTEND); - $header[self::RTP_PAYLOAD_INDEX] = pack(FormatPackEnum::C->value, self::RTP_PAYLOAD_TYPE); - return $header->writeShort($this->seq, self::SEQ_INDEX) - ->writeUInt($this->timestamp, self::TIMESTAMP_INDEX) - ->writeUInt($this->ssrc, self::SSRC_INDEX); + $header = new Buffer(HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value); + $header[HeaderValuesEnum::RTP_VERSION_PAD_EXTEND_INDEX->value] = pack(FormatPackEnum::C->value, HeaderValuesEnum::RTP_VERSION_PAD_EXTEND->value); + $header[HeaderValuesEnum::RTP_PAYLOAD_INDEX->value] = pack(FormatPackEnum::C->value, HeaderValuesEnum::RTP_PAYLOAD_TYPE->value); + return $header->writeShort($this->seq, HeaderValuesEnum::SEQ_INDEX->value) + ->writeUInt($this->timestamp, HeaderValuesEnum::TIMESTAMP_OR_NONCE_INDEX->value) + ->writeUInt($this->ssrc, HeaderValuesEnum::SSRC_INDEX->value); } public function setHeader(?string $message = null): ?string @@ -341,7 +310,7 @@ public function setHeader(?string $message = null): ?string return null; } - $this->headerSize = self::RTP_HEADER_BYTE_LENGTH; + $this->headerSize = HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value; $firstByte = ord($message[0]); if (($firstByte >> 4) & 0x01) { $this->headerSize += 4; @@ -392,7 +361,10 @@ public function getSSRC(): int */ public function getData(): string { - return $this->buffer->read(self::RTP_HEADER_BYTE_LENGTH, strlen((string) $this->buffer) - self::RTP_HEADER_BYTE_LENGTH); + return $this->buffer->read( + HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value, + strlen((string) $this->buffer) - HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value + ); } /** @@ -400,9 +372,9 @@ public function getData(): string * * @param string $data Data from Discord. * - * @return VoicePacket A voice packet. + * @return self A voice packet. */ - public static function make(string $data): VoicePacket + public static function make(string $data): self { $n = new self('', 0, 0, 0); $buff = new Buffer($data); @@ -423,9 +395,9 @@ public function setBuffer(Buffer $buffer): self { $this->buffer = $buffer; - $this->seq = $this->buffer->readShort(self::SEQ_INDEX); - $this->timestamp = $this->buffer->readUInt(self::TIMESTAMP_INDEX); - $this->ssrc = $this->buffer->readUInt(self::SSRC_INDEX); + $this->seq = $this->buffer->readShort(HeaderValuesEnum::SEQ_INDEX->value); + $this->timestamp = $this->buffer->readUInt(HeaderValuesEnum::TIMESTAMP_OR_NONCE_INDEX->value); + $this->ssrc = $this->buffer->readUInt(HeaderValuesEnum::SSRC_INDEX->value); return $this; } @@ -437,7 +409,7 @@ public function setBuffer(Buffer $buffer): self */ public function __toString(): string { - return (string) $this->buffer; + return (string) $this?->buffer ?? $this->decryptedAudio ?? ''; } /** diff --git a/src/Discord/Voice/Client/User.php b/src/Discord/Voice/Client/User.php new file mode 100644 index 000000000..6df50a22e --- /dev/null +++ b/src/Discord/Voice/Client/User.php @@ -0,0 +1,22 @@ +data; + } + + if (! $bot) { + self::$bot = $vc->bot; + } + + $f = new Connector(self::$bot->loop); + + /** @var PromiseInterface */ + $f("wss://" . self::$data['endpoint'] . "?v=" . self::$version) + ->then( + static fn (WebSocket $ws) => self::handleConnection($ws), + static fn (\Throwable $e) => self::$bot->logger->error( + 'Failed to connect to voice gateway: {error}', + ['error' => $e->getMessage()] + ) && self::$vc->emit('error', [$e]) + ); + } + + public static function make( + VoiceClient $vc, + ?Discord $bot = null, + ?array $data = null + ): self { + return new self($vc, $bot, $data); + } + + /** + * Handles a WebSocket connection. + * + * @param WebSocket $ws The WebSocket instance. + */ + public static function handleConnection(WebSocket $ws): void + { + self::$bot->logger->debug('connected to voice websocket'); + + $resolver = (new DnsFactory())->createCached(self::$data['dnsConfig'], self::$bot->loop); + $udpfac = new Factory(self::$bot->loop, $resolver); + + self::$socket = self::$vc->voiceWebsocket = $ws; + + $ip = $port = ''; + + $ws->on('message', function (Message $message) use ($udpfac, &$ip, &$port): void { + $data = json_decode($message->getPayload()); + self::$vc->emit('ws-message', [$message, self::$vc]); + + switch ($data->op) { + case Op::VOICE_HEARTBEAT_ACK: // keepalive response + $end = microtime(true); + $start = $data->d->t; + $diff = ($end - $start) * 1000; + + self::$bot->logger->debug('received heartbeat ack', ['response_time' => $diff]); + self::$vc->emit('ws-ping', [$diff]); + self::$vc->emit('ws-heartbeat-ack', [$data->d->t]); + break; + case Op::VOICE_DESCRIPTION: // ready + self::$vc->ready = true; + self::$vc->mode = $data->d->mode; + self::$vc->secretKey = ''; + self::$vc->rawKey = $data->d->secret_key; + self::$vc->secretKey = implode('', array_map(static fn ($value) => pack('C', $value), self::$vc->rawKey)); + + self::$bot->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); + + if (! self::$vc->reconnecting) { + self::$vc->emit('ready', [self::$vc]); + } else { + self::$vc->reconnecting = false; + self::$vc->emit('resumed', [self::$vc]); + } + + if (! self::$vc->deaf && self::$vc->secretKey) { + self::$vc->client->on( + 'message', + fn (string $message) => self::$vc->handleAudioData(new Packet( + $message, + key: self::$vc->secretKey, + log: self::$bot->logger + ))); + } + + break; + case Op::VOICE_SPEAKING: // currently connected users + self::$bot->logger->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); + self::$vc->emit('speaking', [$data->d->speaking, $data->d->user_id, self::$vc]); + self::$vc->emit("speaking.{$data->d->user_id}", [$data->d->speaking, self::$vc]); + self::$vc->speakingStatus[$data->d->user_id] = self::$bot->getFactory()->create(VoiceSpeaking::class, $data->d); + break; + case Op::VOICE_HELLO: + self::$vc->heartbeatInterval = $data->d->heartbeat_interval; + self::sendHeartbeat(); + self::$vc->heartbeat = self::$bot->loop->addPeriodicTimer(self::$vc->heartbeatInterval / 1000, fn () => self::sendHeartbeat()); + break; + case Op::VOICE_CLIENTS_CONNECT: + self::$bot->logger->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); + # "d" contains an array with ['user_ids' => array] + + self::$vc->users = array_map(fn (int $userId) => self::$bot->getFactory()->create(UserConnected::class, $userId), $data->d->user_ids); + break; + case Op::VOICE_CLIENT_DISCONNECT: + self::$bot->logger->debug('received client disconnected packet', ['data' => json_decode(json_encode($data->d), true)]); + unset(self::$vc->clientsConnected[$data->d->user_id]); + break; + case Op::VOICE_CLIENT_UNKNOWN_15: + case Op::VOICE_CLIENT_UNKNOWN_18: + self::$bot->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + break; + case Op::VOICE_CLIENT_PLATFORM: + self::$bot->logger->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); + # handlePlatformPerUser + # platform = 0 assumed to be Desktop + break; + case Op::VOICE_DAVE_PREPARE_TRANSITION: + #$this->handleDavePrepareTransition($data); + break; + case Op::VOICE_DAVE_EXECUTE_TRANSITION: + #$this->handleDaveExecuteTransition($data); + break; + case Op::VOICE_DAVE_TRANSITION_READY: + #$this->handleDaveTransitionReady($data); + break; + case Op::VOICE_DAVE_PREPARE_EPOCH: + #$this->handleDavePrepareEpoch($data); + break; + case Op::VOICE_DAVE_MLS_EXTERNAL_SENDER: + #$this->handleDaveMlsExternalSender($data); + break; + case Op::VOICE_DAVE_MLS_KEY_PACKAGE: + #$this->handleDaveMlsKeyPackage($data); + break; + case Op::VOICE_DAVE_MLS_PROPOSALS: + #$this->handleDaveMlsProposals($data); + break; + case Op::VOICE_DAVE_MLS_COMMIT_WELCOME: + #$this->handleDaveMlsCommitWelcome($data); + break; + case Op::VOICE_DAVE_MLS_ANNOUNCE_COMMIT_TRANSITION: + #$this->handleDaveMlsAnnounceCommitTransition($data); + break; + case Op::VOICE_DAVE_MLS_WELCOME: + #$this->handleDaveMlsWelcome($data); + break; + case Op::VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME: + #$this->handleDaveMlsInvalidCommitWelcome($data); + break; + + case Op::VOICE_READY: { + self::$vc->udpPort = $data->d->port; + self::$vc->ssrc = $data->d->ssrc; + + self::$bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + + $buffer = new Buffer(74); + $buffer[1] = "\x01"; + $buffer[3] = "\x46"; + $buffer->writeUInt32BE(self::$vc->ssrc, 4); + /** @var PromiseInterface */ + $udpfac->createClient("{$data->d->ip}:" . self::$vc->udpPort)->then(function (Socket $client) use (&$ip, &$port, $buffer): void { + self::$bot->logger->debug('connected to voice UDP'); + self::$vc->client = $client; + + self::$bot->loop->addTimer(0.1, fn () => self::$vc->client->send($buffer->__toString())); + + self::$vc->udpHeartbeat = self::$bot->loop->addPeriodicTimer(self::$vc->heartbeatInterval / 1000, function (): void { + $buffer = new Buffer(9); + $buffer[0] = 0xC9; + $buffer->writeUInt64LE(self::$vc->heartbeatSeq, 1); + ++self::$vc->heartbeatSeq; + + self::$vc->client->send($buffer->__toString()); + self::$vc->emit('udp-heartbeat', []); + + self::$bot->logger->debug('sent UDP heartbeat'); + }); + + $client->on('error', fn ($e) => self::$vc->emit('udp-error', [$e])); + + #$client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); + }, function (\Throwable $e): void { + self::$bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); + self::$vc->emit('error', [$e]); + }); + break; + } + default: + self::$bot->logger->warning('Unknown opcode.', $data); + break; + } + }); + + $ws->on('error', function ($e): void { + self::$bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); + self::$vc->emit('ws-error', [$e]); + }); + + //$ws->on('close', [$this, 'handleClose']); + + + if (self::$vc->sentLoginFrame) { + return; + } + + $payload = VoicePayload::new( + Op::VOICE_IDENTIFY, + [ + 'server_id' => self::$vc->channel->guild_id, + 'user_id' => self::$data['user_id'], + 'session_id' => self::$data['session'], + 'token' => self::$data['token'], + ], + ); + + self::$bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); + + self::send($payload); + self::$vc->sentLoginFrame = true; + } + + /** + * Sends a message to the voice websocket. + * + * @param VoicePayload|array $data The data to send to the voice WebSocket. + */ + public static function send(VoicePayload|array $data): void + { + $json = json_encode($data); + self::$socket->send($json); + } + + /** + * Monitor a process for exit and trigger callbacks when it exits + * + * @param Process $process The process to monitor + * @param object $ss The speaking status object + * @param callable $createDecoder Function to create a new decoder if needed + */ + /* protected function monitorProcessExit(Process $process, $ss): void + { + // Store the process ID + // $pid = $process->getPid(); + + // Check every second if the process is still running + self::$monitorProcessTimer = self::$bot->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { + // Check if the process is still running + if (!$process->isRunning()) { + // Get the exit code + $exitCode = $process->getExitCode(); + + // Clean up the timer + self::$bot->loop->cancelTimer($this->monitorProcessTimer); + + // If exit code indicates an error, emit event and recreate decoder + if ($exitCode > 0) { + $this->emit('decoder-error', [$exitCode, null, $ss]); + $this->createDecoder($ss); + } + + // Clean up temporary files + // $this->cleanupTempFiles(); + } + }); + } */ + + protected static function handleDavePrepareTransition($data) + { + self::$bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); + // Prepare local state necessary to perform the transition + self::send(VoicePayload::new( + Op::VOICE_DAVE_TRANSITION_READY, + [ + 'transition_id' => $data->d->transition_id, + ], + )); + } + + protected static function handleDaveExecuteTransition($data) + { + self::$bot->logger->debug('DAVE Execute Transition', ['data' => $data]); + // Execute the transition + // Update local state to reflect the new protocol context + } + + protected static function handleDaveTransitionReady($data) + { + self::$bot->logger->debug('DAVE Transition Ready', ['data' => $data]); + // Handle transition ready state + } + + protected static function handleDavePrepareEpoch($data) + { + self::$bot->logger->debug('DAVE Prepare Epoch', ['data' => $data]); + // Prepare local MLS group with parameters appropriate for the DAVE protocol version + self::send(VoicePayload::new( + Op::VOICE_DAVE_MLS_KEY_PACKAGE, + [ + 'epoch_id' => $data->d->epoch_id, + //'key_package' => $this->generateKeyPackage(), + ], + )); + } + + protected static function handleDaveMlsExternalSender($data) + { + self::$bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); + // Handle external sender public key and credential + } + + protected static function handleDaveMlsKeyPackage($data) + { + self::$bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); + // Handle MLS key package + } + + protected static function handleDaveMlsProposals($data) + { + self::$bot->logger->debug('DAVE MLS Proposals', ['data' => $data]); + // Handle MLS proposals + self::send(VoicePayload::new( + Op::VOICE_DAVE_MLS_COMMIT_WELCOME, + [ + //'commit' => $this->generateCommit(), + //'welcome' => $this->generateWelcome(), + ], + )); + } + + protected static function handleDaveMlsCommitWelcome($data) + { + self::$bot->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); + // Handle MLS commit and welcome messages + } + + protected static function handleDaveMlsAnnounceCommitTransition($data) + { + // Handle MLS announce commit transition + self::$bot->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); + } + + protected static function handleDaveMlsWelcome($data) + { + // Handle MLS welcome message + self::$bot->logger->debug('DAVE MLS Welcome', ['data' => $data]); + } + + protected static function handleDaveMlsInvalidCommitWelcome($data) + { + self::$bot->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); + // Handle invalid commit or welcome message + // Reset local group state and generate a new key package + self::send(VoicePayload::new( + Op::VOICE_DAVE_MLS_KEY_PACKAGE, + [ + //'key_package' => $this->generateKeyPackage(), + ], + )); + } + + #protected function decodeUDP($message, string &$ip, string &$port): void + #{ + /** + * Unpacks the message into an array. + * + * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively + * n (unsigned short) | Length | 2 bytes | Length of the following data + * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender + * A64 (string) | Address | 64 bytes | The IP address of the sender + * n (unsigned short) | Port | 2 bytes | The port of the sender + * + * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery + * @see https://www.php.net/manual/en/function.unpack.php + * @see https://www.php.net/manual/en/function.pack.php For the formats + */ + /* $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); + + $this->ssrc = $unpackedMessageArray['SSRC']; + $ip = $unpackedMessageArray['Address']; + $port = $unpackedMessageArray['Port']; + + $this->bot->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + + $this->send([ + 'op' => Op::VOICE_SELECT_PROTO, + 'd' => [ + 'protocol' => 'udp', + 'data' => [ + 'address' => $ip, + 'port' => $port, + 'mode' => $this->mode, + ], + ], + ]); */ + #} + + public static function sendHeartbeat(): void + { + self::send(VoicePayload::new( + Op::VOICE_HEARTBEAT, + [ + 't' => (int) microtime(true), + 'seq_ack' => 10, + ] + )); + self::$bot->logger->debug('sending heartbeat'); + self::$vc->emit('ws-heartbeat', []); + } + + // TODO still need to convert to static + public static function handleClose(int $op, string $reason): void + { + $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); + $this->emit('ws-close', [$op, $reason, $this]); + + $this->clientsConnected = []; + $this->voiceWebsocket->close(); + + // Cancel heartbeat timers + if (null !== $this->heartbeat) { + $this->bot->loop->cancelTimer($this->heartbeat); + $this->heartbeat = null; + } + + if (null !== $this->udpHeartbeat) { + $this->bot->loop->cancelTimer($this->udpHeartbeat); + $this->udpHeartbeat = null; + } + + // Close UDP socket. + if (isset($this->client)) { + $this->bot->logger->warning('closing UDP client'); + $this->client->close(); + } + + // Don't reconnect on a critical opcode or if closed by user. + if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this->userClose) { + $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); + $this->emit('close'); + + return; + } + + if (in_array($op, [Op::CLOSE_VOICE_DISCONNECTED])) { + $this->emit('close'); + + return; + } + + $this->bot->logger->warning('reconnecting in 2 seconds'); + + // Retry connect after 2 seconds + $this->bot->loop->addTimer(2, function (): void { + $this->reconnecting = true; + $this->sentLoginFrame = false; + + $this->start(); + }); + } +} diff --git a/src/Discord/Voice/Processes/Dca.php b/src/Discord/Voice/Processes/Dca.php new file mode 100644 index 000000000..e2d747f42 --- /dev/null +++ b/src/Discord/Voice/Processes/Dca.php @@ -0,0 +1,91 @@ + Status of people speaking. + * @var ExCollectionInterface Status of people speaking. */ - protected $speakingStatus; + public $speakingStatus; /** * Collection of voice decoders. * * @var ExCollectionInterface Voice decoders. */ - protected $voiceDecoders; + public $voiceDecoders; /** * Voice audio recieve streams. @@ -252,14 +254,14 @@ class VoiceClient extends EventEmitter * * @var array|null Voice audio recieve streams. */ - protected $recieveStreams; + public $recieveStreams; /** * Voice audio receive streams. * * @var array|null Voice audio recieve streams. */ - protected $receiveStreams; + public $receiveStreams; /** * The volume the audio will be encoded with. @@ -289,14 +291,14 @@ class VoiceClient extends EventEmitter * * @var bool Whether the voice client is reconnecting. */ - protected $reconnecting = false; + public $reconnecting = false; /** * Is the voice client being closed by user? * * @var bool Whether the voice client is being closed by user. */ - protected $userClose = false; + public $userClose = false; /** * The Discord voice gateway version. @@ -305,35 +307,35 @@ class VoiceClient extends EventEmitter * * @var int Voice version. */ - protected $version = 8; + public $version = 8; /** * The Config for DNS Resolver. * * @var Config|string|null */ - protected $dnsConfig; + public $dnsConfig; /** * Silence Frame Remain Count. * * @var int Amount of silence frames remaining. */ - protected $silenceRemaining = 5; + public $silenceRemaining = 5; /** * readopus Timer. * * @var TimerInterface Timer */ - protected $readOpusTimer; + public $readOpusTimer; /** * Audio Buffer. * * @var RealBuffer|null The Audio Buffer */ - protected $buffer; + public $buffer; /** * Current clients connected to the voice chat @@ -369,11 +371,11 @@ class VoiceClient extends EventEmitter * @param bool $mute Default: false */ public function __construct( - protected Discord $bot, - protected Channel $channel, - protected array $data, - protected bool $deaf = false, - protected bool $mute = false, + public Discord $bot, + public Channel $channel, + public array $data, + public bool $deaf = false, + public bool $mute = false, protected ?Deferred $deferred = null, protected ?VoiceManager &$manager = null, ) { @@ -400,269 +402,10 @@ public function start(): bool return false; } - $this->initSockets(); + Ws::make($this); return true; } - /** - * Initilizes the WebSocket and UDP socket. - */ - public function initSockets(): void - { - $wsfac = new WsFactory($this->bot->loop); - /** @var PromiseInterface */ - $promise = $wsfac("wss://{$this->endpoint}?v={$this->version}"); - - $promise->then([$this, 'handleWebSocketConnection'], [$this, 'handleWebSocketError']); - } - - /** - * Handles a WebSocket connection. - * - * @param WebSocket $ws The WebSocket instance. - */ - public function handleWebSocketConnection(WebSocket $ws): void - { - $this->bot->logger->debug('connected to voice websocket'); - - $resolver = (new DNSFactory())->createCached($this->dnsConfig, $this->bot->loop); - $udpfac = new DatagramFactory($this->bot->loop, $resolver); - - $this->voiceWebsocket = $ws; - - $ip = $port = ''; - - $ws->on('message', function (Message $message) use ($udpfac, &$ip, &$port): void { - $data = json_decode($message->getPayload()); - $this->emit('ws-message', [$message, $this]); - - switch ($data->op) { - case Op::VOICE_HEARTBEAT_ACK: // keepalive response - $end = microtime(true); - $start = $data->d->t; - $diff = ($end - $start) * 1000; - - $this->bot->logger->debug('received heartbeat ack', ['response_time' => $diff]); - $this->emit('ws-ping', [$diff]); - $this->emit('ws-heartbeat-ack', [$data->d->t]); - break; - case Op::VOICE_DESCRIPTION: // ready - $this->ready = true; - $this->mode = $data->d->mode; - $this->secretKey = ''; - $this->rawKey = $data->d->secret_key; - $this->secretKey = implode('', array_map(fn ($value) => pack('C', $value), $this->rawKey)); - - $this->bot->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); - - if (! $this->reconnecting) { - $this->emit('ready', [$this]); - } else { - $this->reconnecting = false; - $this->emit('resumed', [$this]); - } - - if (! $this->deaf && $this->secretKey) { - $this->client->on( - 'message', - fn (string $message) => $this->handleAudioData(new VoicePacket( - $message, - key: $this->secretKey, - log: $this->bot->logger - ))); - } - - break; - case Op::VOICE_SPEAKING: // currently connected users - $this->bot->logger->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); - $this->emit('speaking', [$data->d->speaking, $data->d->user_id, $this]); - $this->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this]); - $this->speakingStatus[$data->d->user_id] = $this->bot->getFactory()->create(VoiceSpeaking::class, $data->d); - break; - case Op::VOICE_HELLO: - $this->heartbeatInterval = $data->d->heartbeat_interval; - $this->sendHeartbeat(); - $this->heartbeat = $this->bot->loop->addPeriodicTimer($this->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); - break; - case Op::VOICE_CLIENTS_CONNECT: - $this->bot->logger->debug('received clients connect packet', ['data' => json_decode(json_encode($data->d), true)]); - # "d" contains an array with ['user_ids' => array] - $this->clientsConnected = $data->d->user_ids; - break; - case Op::VOICE_CLIENT_DISCONNECT: - unset($this->clientsConnected[$data->d->user_id]); - break; - case Op::VOICE_CLIENT_UNKNOWN_15: - case Op::VOICE_CLIENT_UNKNOWN_18: - $this->bot->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); - break; - case Op::VOICE_CLIENT_PLATFORM: - $this->bot->logger->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); - # handlePlatformPerUser - # platform = 0 assumed to be Desktop - break; - case Op::VOICE_DAVE_PREPARE_TRANSITION: - $this->handleDavePrepareTransition($data); - break; - case Op::VOICE_DAVE_EXECUTE_TRANSITION: - $this->handleDaveExecuteTransition($data); - break; - case Op::VOICE_DAVE_TRANSITION_READY: - $this->handleDaveTransitionReady($data); - break; - case Op::VOICE_DAVE_PREPARE_EPOCH: - $this->handleDavePrepareEpoch($data); - break; - case Op::VOICE_DAVE_MLS_EXTERNAL_SENDER: - $this->handleDaveMlsExternalSender($data); - break; - case Op::VOICE_DAVE_MLS_KEY_PACKAGE: - $this->handleDaveMlsKeyPackage($data); - break; - case Op::VOICE_DAVE_MLS_PROPOSALS: - $this->handleDaveMlsProposals($data); - break; - case Op::VOICE_DAVE_MLS_COMMIT_WELCOME: - $this->handleDaveMlsCommitWelcome($data); - break; - case Op::VOICE_DAVE_MLS_ANNOUNCE_COMMIT_TRANSITION: - $this->handleDaveMlsAnnounceCommitTransition($data); - break; - case Op::VOICE_DAVE_MLS_WELCOME: - $this->handleDaveMlsWelcome($data); - break; - case Op::VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME: - $this->handleDaveMlsInvalidCommitWelcome($data); - break; - - case Op::VOICE_READY: { - $this->udpPort = $data->d->port; - $this->ssrc = $data->d->ssrc; - - $this->bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); - - $buffer = new Buffer(74); - $buffer[1] = "\x01"; - $buffer[3] = "\x46"; - $buffer->writeUInt32BE($this->ssrc, 4); - /** @var PromiseInterface */ - $udpfac->createClient("{$data->d->ip}:{$this->udpPort}")->then(function (Socket $client) use (&$ip, &$port, $buffer): void { - $this->bot->logger->debug('connected to voice UDP'); - $this->client = $client; - - $this->bot->loop->addTimer(0.1, fn () => $this->client->send($buffer->__toString())); - - $this->udpHeartbeat = $this->bot->loop->addPeriodicTimer($this->heartbeatInterval / 1000, function (): void { - $buffer = new Buffer(9); - $buffer[0] = 0xC9; - $buffer->writeUInt64LE($this->heartbeatSeq, 1); - ++$this->heartbeatSeq; - - $this->client->send($buffer->__toString()); - $this->emit('udp-heartbeat', []); - - $this->bot->logger->debug('sent UDP heartbeat'); - }); - - $client->on('error', fn ($e) => $this->emit('udp-error', [$e])); - - $client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); - }, function (\Throwable $e): void { - $this->bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); - $this->emit('error', [$e]); - }); - break; - } - default: - $this->bot->logger->warning('Unknown opcode.', $data); - break; - } - }); - - $ws->on('error', function ($e): void { - $this->bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); - $this->emit('ws-error', [$e]); - }); - - $ws->on('close', [$this, 'handleWebSocketClose']); - - if (! $this->sentLoginFrame) { - $payload = VoicePayload::new( - Op::VOICE_IDENTIFY, - [ - 'server_id' => $this->channel->guild_id, - 'user_id' => $this->data['user_id'], - 'session_id' => $this->data['session'], - 'token' => $this->data['token'], - ], - ); - - $this->bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); - - $this->send($payload); - $this->sentLoginFrame = true; - } - } - - protected function decodeUDP($message, string &$ip, string &$port): void - { - /** - * Unpacks the message into an array. - * - * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively - * n (unsigned short) | Length | 2 bytes | Length of the following data - * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender - * A64 (string) | Address | 64 bytes | The IP address of the sender - * n (unsigned short) | Port | 2 bytes | The port of the sender - * - * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery - * @see https://www.php.net/manual/en/function.unpack.php - * @see https://www.php.net/manual/en/function.pack.php For the formats - */ - $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); - - $this->ssrc = $unpackedMessageArray['SSRC']; - $ip = $unpackedMessageArray['Address']; - $port = $unpackedMessageArray['Port']; - - $this->bot->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); - - $this->send([ - 'op' => Op::VOICE_SELECT_PROTO, - 'd' => [ - 'protocol' => 'udp', - 'data' => [ - 'address' => $ip, - 'port' => $port, - 'mode' => $this->mode, - ], - ], - ]); - } - - protected function sendHeartbeat(): void - { - $this->send(VoicePayload::new( - Op::VOICE_HEARTBEAT, - [ - 't' => (int) microtime(true), - 'seq_ack' => 10, - ] - )); - $this->bot->logger->debug('sending heartbeat'); - $this->emit('ws-heartbeat', []); - } - - /** - * Handles a WebSocket error. - * - * @param \Exception $e The error. - */ - public function handleWebSocketError(\Exception $e): void - { - $this->bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); - $this->emit('error', [$e]); - } /** * Handles a WebSocket close. @@ -676,6 +419,7 @@ public function handleWebSocketClose(int $op, string $reason): void $this->emit('ws-close', [$op, $reason, $this]); $this->clientsConnected = []; + $this->voiceWebsocket->close(); // Cancel heartbeat timers if (null !== $this->heartbeat) { @@ -698,6 +442,13 @@ public function handleWebSocketClose(int $op, string $reason): void if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this->userClose) { $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); $this->emit('close'); + + return; + } + + if (in_array($op, [Op::CLOSE_VOICE_DISCONNECTED])) { + $this->emit('close'); + return; } @@ -708,7 +459,7 @@ public function handleWebSocketClose(int $op, string $reason): void $this->reconnecting = true; $this->sentLoginFrame = false; - $this->initSockets(); + $this->start(); }); } @@ -733,13 +484,13 @@ public function handleVoiceServerChange(array $data = []): void $this->data['token'] = $data['token']; // set the token if it changed $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - $this->initSockets(); + $this->start(); $this->on('resumed', function () { $this->bot->logger->debug('voice client resumed'); $this->unpause(); $this->speaking = false; - $this->setSpeaking(true); + //$this->setSpeaking(true); }); } @@ -890,7 +641,7 @@ public function playOggStream($stream): PromiseInterface $loops = 0; - $this->setSpeaking(true); + #$this->setSpeaking(true); OggStream::fromBuffer($this->buffer)->then(function (OggStream $os) use ($deferred, &$ogg, &$loops) { $ogg = $os; @@ -1009,11 +760,11 @@ public function playDCAStream($stream): PromiseInterface $this->buffer->write($d); }); - $this->setSpeaking(true); + #$this->setSpeaking(true); // Read magic byte header $this->buffer->read(4)->then(function ($mb) { - if ($mb !== self::DCA_VERSION) { + if ($mb !== Dca::DCA_VERSION) { throw new OutdatedDCAException('The DCA magic byte header was not correct.'); } @@ -1091,7 +842,7 @@ protected function reset(): void $this->readOpusTimer = null; } - $this->setSpeaking(false); + #$this->setSpeaking(false); $this->streamTime = 0; $this->startTime = 0; $this->paused = false; @@ -1110,7 +861,7 @@ protected function sendBuffer(string $data): void return; } - $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secretKey, log: $this->bot->logger); + $packet = new Packet($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secretKey, log: $this->bot->logger); $this->client->send((string) $packet); $this->streamTime = (int) microtime(true); @@ -1125,7 +876,7 @@ protected function sendBuffer(string $data): void * * @throws \RuntimeException */ - public function setSpeaking(bool $speaking = true): void + /* public function setSpeaking(bool $speaking = true): void { if ($this->speaking == $speaking) { return; @@ -1145,7 +896,7 @@ public function setSpeaking(bool $speaking = true): void )); $this->speaking = $speaking; - } + } */ /** * Switches voice channels. @@ -1238,16 +989,7 @@ public function setAudioApplication(string $app): void $this->audioApplication = $app; } - /** - * Sends a message to the voice websocket. - * - * @param Payload|array $data The data to send to the voice WebSocket. - */ - protected function send($data): void - { - $json = json_encode($data); - $this->voiceWebsocket->send($json); - } + /** * Sends a message to the main websocket. @@ -1361,7 +1103,7 @@ public function close(): void if ($this->speaking) { $this->stop(); - $this->setSpeaking(false); + #$this->setSpeaking(false); } $this->ready = false; @@ -1520,7 +1262,7 @@ public function getReceiveStream($id) * * @param string $message The data from the UDP server. */ - protected function handleAudioData(VoicePacket $voicePacket): void + public function handleAudioData(Packet $voicePacket): void { $message = $voicePacket?->decryptedAudio ?? null; @@ -1603,170 +1345,14 @@ protected function createDecoder($ss): void $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); }); - /* // Handle stdout - $stdoutHandle = fopen($this->tempFiles['stdout'], 'r'); - $this->discord->loop->addPeriodicTimer(0.1, function () use ($stdoutHandle, $ss) { - $data = fread($stdoutHandle, 8192); - if ($data) { - $this->receiveStreams[$ss->ssrc]->writePCM($data); - } - }); - - // Handle stderr - $stderrHandle = fopen($this->tempFiles['stderr'], 'r'); - $this->discord->loop->addPeriodicTimer(0.1, function () use ($stderrHandle, $ss) { - $data = fread($stderrHandle, 8192); - if ($data) { - $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); - $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); - } - }); */ - // Store the decoder $this->voiceDecoders[$ss->ssrc] = $decoder; // Monitor the process for exit - $this->monitorProcessExit($decoder, $ss); - } - - /** - * Monitor a process for exit and trigger callbacks when it exits - * - * @param Process $process The process to monitor - * @param object $ss The speaking status object - * @param callable $createDecoder Function to create a new decoder if needed - */ - protected function monitorProcessExit(Process $process, $ss): void - { - // Store the process ID - // $pid = $process->getPid(); - - // Check every second if the process is still running - $this->monitorProcessTimer = $this->bot->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { - // Check if the process is still running - if (!$process->isRunning()) { - // Get the exit code - $exitCode = $process->getExitCode(); - - // Clean up the timer - $this->bot->loop->cancelTimer($this->monitorProcessTimer); - - // If exit code indicates an error, emit event and recreate decoder - if ($exitCode > 0) { - $this->emit('decoder-error', [$exitCode, null, $ss]); - $this->createDecoder($ss); - } - - // Clean up temporary files - $this->cleanupTempFiles(); - } - }); - } - - protected function cleanupTempFiles(): void - { - if (isset($this->tempFiles)) { - foreach ($this->tempFiles as $file) { - if (file_exists($file)) { - unlink($file); - } - } - } - } - - protected function handleDavePrepareTransition($data) - { - $this->bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); - // Prepare local state necessary to perform the transition - $this->send(VoicePayload::new( - Op::VOICE_DAVE_TRANSITION_READY, - [ - 'transition_id' => $data->d->transition_id, - ], - )); - } - - protected function handleDaveExecuteTransition($data) - { - $this->bot->logger->debug('DAVE Execute Transition', ['data' => $data]); - // Execute the transition - // Update local state to reflect the new protocol context - } - - protected function handleDaveTransitionReady($data) - { - $this->bot->logger->debug('DAVE Transition Ready', ['data' => $data]); - // Handle transition ready state - } - - protected function handleDavePrepareEpoch($data) - { - $this->bot->logger->debug('DAVE Prepare Epoch', ['data' => $data]); - // Prepare local MLS group with parameters appropriate for the DAVE protocol version - $this->send(VoicePayload::new( - Op::VOICE_DAVE_MLS_KEY_PACKAGE, - [ - 'epoch_id' => $data->d->epoch_id, - 'key_package' => $this->generateKeyPackage(), - ], - )); - } - - protected function handleDaveMlsExternalSender($data) - { - $this->bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); - // Handle external sender public key and credential - } - - protected function handleDaveMlsKeyPackage($data) - { - $this->bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); - // Handle MLS key package + #$this->monitorProcessExit($decoder, $ss); } - protected function handleDaveMlsProposals($data) - { - $this->bot->logger->debug('DAVE MLS Proposals', ['data' => $data]); - // Handle MLS proposals - $this->send(VoicePayload::new( - Op::VOICE_DAVE_MLS_COMMIT_WELCOME, - [ - 'commit' => $this->generateCommit(), - 'welcome' => $this->generateWelcome(), - ], - )); - } - protected function handleDaveMlsCommitWelcome($data) - { - $this->bot->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); - // Handle MLS commit and welcome messages - } - - protected function handleDaveMlsAnnounceCommitTransition($data) - { - // Handle MLS announce commit transition - $this->bot->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); - } - - protected function handleDaveMlsWelcome($data) - { - // Handle MLS welcome message - $this->bot->logger->debug('DAVE MLS Welcome', ['data' => $data]); - } - - protected function handleDaveMlsInvalidCommitWelcome($data) - { - $this->bot->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); - // Handle invalid commit or welcome message - // Reset local group state and generate a new key package - $this->send(VoicePayload::new( - Op::VOICE_DAVE_MLS_KEY_PACKAGE, - [ - 'key_package' => $this->generateKeyPackage(), - ], - )); - } protected function generateKeyPackage() { @@ -1818,32 +1404,6 @@ public function getDbVolume(): float|int }; } - /** - * Decodes a file from Opus with DCA. - * - * @param int $channels How many audio channels to decode with. - * @param int|null $frameSize The Opus packet frame size. - * - * @return Process A ReactPHP Child Process - */ - public function dcaDecode(int $channels = 2, ?int $frameSize = null): Process - { - if (null === $frameSize) { - $frameSize = round($this->frameSize * 48); - } - - $flags = [ - '-ac', $channels, // Channels - '-ab', round($this->bitrate / 1000), // Bitrate - '-as', $frameSize, // Frame Size - '-mode', 'decode', // Decode mode - ]; - - $flags = implode(' ', $flags); - - return new Process("{$this->dca} {$flags}"); - } - /** * Returns the connected channel. * diff --git a/src/Discord/WebSockets/OpEnum.php b/src/Discord/WebSockets/OpEnum.php new file mode 100644 index 000000000..1b4fb84ae --- /dev/null +++ b/src/Discord/WebSockets/OpEnum.php @@ -0,0 +1,342 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\WebSockets; + +/** + * Contains constants used in websockets. + * + * @link https://discord.com/developers/docs/topics/opcodes-and-status-codes + * + * @since 3.2.1 + */ +enum OpEnum: int +{ + /** + * Gateway Opcodes. + * + * All gateway events in Discord are tagged with an opcode that denotes the + * payload type. Your connection to our gateway may also sometimes close. + * When it does, you will receive a close code that tells you what happened. + * + * @link https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-opcodes + */ + + /** Dispatches an event. */ + case OP_DISPATCH = 0; + /** Used for ping checking. */ + case OP_HEARTBEAT = 1; + /** Used for client handshake. */ + case OP_IDENTIFY = 2; + /** Used to update the client presence. */ + case OP_PRESENCE_UPDATE = 3; + /** Used to join/move/leave voice channels. */ + case OP_VOICE_STATE_UPDATE = 4; + /** Used for voice ping checking. */ + case OP_VOICE_SERVER_PING = 5; + /** Used to resume a closed connection. */ + case OP_RESUME = 6; + /** Used to redirect clients to a new gateway. */ + case OP_RECONNECT = 7; + /** Used to request member chunks. */ + case OP_GUILD_MEMBER_CHUNK = 8; + /** Used to notify clients when they have an invalid session. */ + case OP_INVALID_SESSION = 9; + /** Used to pass through the heartbeat interval. */ + case OP_HELLO = 10; + /** Used to acknowledge heartbeats. */ + case OP_HEARTBEAT_ACK = 11; + /** Request soundboard sounds. */ + case REQUEST_SOUNDBOARD_SOUNDS = 31; + + /** + * Voice Opcodes. + * + * Our voice gateways have their own set of opcodes and close codes. + * + * @link https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-opcodes + */ + + /** Used to begin a voice WebSocket connection. */ + case VOICE_IDENTIFY = 0; + /** Used to select the voice protocol. */ + case VOICE_SELECT_PROTO = 1; + /** Used to complete the WebSocket handshake. */ + case VOICE_READY = 2; + /** Used to keep the WebSocket connection alive. */ + case VOICE_HEARTBEAT = 3; + /** Used to describe the session. */ + case VOICE_DESCRIPTION = 4; + /** Used to identify which users are speaking. */ + case VOICE_SPEAKING = 5; + /** Sent by the Discord servers to acknowledge heartbeat */ + case VOICE_HEARTBEAT_ACK = 6; + /** Resume a connection. */ + case VOICE_RESUME = 7; + /** Hello packet used to pass heartbeat interval */ + case VOICE_HELLO = 8; + /** Acknowledge a successful session resume. */ + case VOICE_RESUMED = 9; + /** One or more clients have connected to the voice channel */ + case VOICE_CLIENTS_CONNECT = 11; + case VOICE_CLIENT_CONNECT = 11; // Deprecated, used VOICE_CLIENTS_CONNECT instead + /** A client has disconnected from the voice channel. */ + case VOICE_CLIENT_DISCONNECT = 13; + /** Was not documented within the op codes and statuses*/ + case VOICE_CLIENT_UNKNOWN_15 = 15; + case VOICE_CLIENT_UNKNOWN_18 = 18; + /** NOT DOCUMENTED - Assumed to be the platform type in which the user is. */ + case VOICE_CLIENT_PLATFORM = 20; + /** A downgrade from the DAVE protocol is upcoming. */ + case VOICE_DAVE_PREPARE_TRANSITION = 21; + /** Execute a previously announced protocol transition. */ + case VOICE_DAVE_EXECUTE_TRANSITION = 22; + /** Acknowledge readiness previously announced transition. */ + case VOICE_DAVE_TRANSITION_READY = 23; + /** A DAVE protocol version or group change is upcoming. */ + case VOICE_DAVE_PREPARE_EPOCH = 24; + /** Credential and public key for MLS external sender. */ + case VOICE_DAVE_MLS_EXTERNAL_SENDER = 25; + /** MLS Key Package for pending group member. */ + case VOICE_DAVE_MLS_KEY_PACKAGE = 26; + /** MLS Proposals to be appended or revoked. */ + case VOICE_DAVE_MLS_PROPOSALS = 27; + /** MLS Commit with optional MLS Welcome messages. */ + case VOICE_DAVE_MLS_COMMIT_WELCOME = 28; + /** MLS Commit to be processed for upcoming transition. */ + case VOICE_DAVE_MLS_ANNOUNCE_COMMIT_TRANSITION = 29; + /** MLS Welcome to group for upcoming transition. */ + case VOICE_DAVE_MLS_WELCOME = 30; + /** Flag invalid commit or welcome, request re-add */ + case VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME = 31; + + /** + * Gateway Close Event Codes. + * + * In order to prevent broken reconnect loops, you should consider some + * close codes as a signal to stop reconnecting. This can be because your + * token expired, or your identification is invalid. This table explains + * what the application defined close codes for the gateway are, and which + * close codes you should not attempt to reconnect. + * + * @link https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-gateway-close-event-codes + */ + + /** Normal close or heartbeat is invalid. */ + case CLOSE_NORMAL = 1000; + /** Abnormal close. */ + case CLOSE_ABNORMAL = 1006; + /** Unknown error. */ + case CLOSE_UNKNOWN_ERROR = 4000; + /** Unknown opcode was sent. */ + case CLOSE_INVALID_OPCODE = 4001; + /** Invalid message was sent. */ + case CLOSE_INVALID_MESSAGE = 4002; + /** Not authenticated. */ + case CLOSE_NOT_AUTHENTICATED = 4003; + /** Invalid token on IDENTIFY. */ + case CLOSE_INVALID_TOKEN = 4004; + /** Already authenticated. */ + case CONST_ALREADY_AUTHD = 4005; + /** Session is invalid. */ + case CLOSE_INVALID_SESSION = 4006; + /** Invalid RESUME sequence. */ + case CLOSE_INVALID_SEQ = 4007; + /** Too many messages sent. */ + case CLOSE_TOO_MANY_MSG = 4008; + /** Session timeout. */ + case CLOSE_SESSION_TIMEOUT = 4009; + /** Invalid shard. */ + case CLOSE_INVALID_SHARD = 4010; + /** Sharding required. */ + case CLOSE_SHARDING_REQUIRED = 4011; + /** Invalid API version. */ + case CLOSE_INVALID_VERSION = 4012; + /** Invalid intents. */ + case CLOSE_INVALID_INTENTS = 4013; + /** Disallowed intents. */ + case CLOSE_DISALLOWED_INTENTS = 4014; + + /** + * Voice Close Event Codes. + * + * @link https://discord.com/developers/docs/topics/opcodes-and-status-codes#voice-voice-close-event-codes + */ + + /** Can't find the server. */ + case CLOSE_VOICE_SERVER_NOT_FOUND = 4011; + /** Unknown protocol. */ + case CLOSE_VOICE_UNKNOWN_PROTO = 4012; + /** Disconnected from channel. */ + case CLOSE_VOICE_DISCONNECTED = 4014; + /** Voice server crashed. */ + case CLOSE_VOICE_SERVER_CRASH = 4015; + /** Unknown encryption mode. */ + case CLOSE_VOICE_UNKNOWN_ENCRYPT = 4016; + + /** + * Returns the critical event codes that we should not reconnect after. + * + * @return array + */ + public static function getCriticalCloseCodes(): array + { + return [ + self::CLOSE_INVALID_TOKEN, + self::CLOSE_SHARDING_REQUIRED, + self::CLOSE_INVALID_SHARD, + self::CLOSE_INVALID_VERSION, + self::CLOSE_INVALID_INTENTS, + self::CLOSE_DISALLOWED_INTENTS, + ]; + } + + /** + * Returns the critical event codes for a voice websocket. + * + * @return array + */ + public static function getCriticalVoiceCloseCodes(): array + { + return [ + self::CLOSE_INVALID_SESSION, + self::CLOSE_INVALID_TOKEN, + self::CLOSE_VOICE_SERVER_NOT_FOUND, + self::CLOSE_VOICE_UNKNOWN_PROTO, + self::CLOSE_VOICE_UNKNOWN_ENCRYPT, + ]; + } + + public static function getVoiceCodes(): array + { + return [ + self::VOICE_IDENTIFY, + self::VOICE_SELECT_PROTO, + self::VOICE_READY, + self::VOICE_HEARTBEAT, + self::VOICE_DESCRIPTION, + self::VOICE_SPEAKING, + self::VOICE_HEARTBEAT_ACK, + self::VOICE_RESUME, + self::VOICE_HELLO, + self::VOICE_RESUMED, + self::VOICE_CLIENTS_CONNECT, + self::VOICE_CLIENT_CONNECT, + self::VOICE_CLIENT_DISCONNECT, + self::VOICE_CLIENT_UNKNOWN_15, + self::VOICE_CLIENT_UNKNOWN_18, + self::VOICE_CLIENT_PLATFORM, + self::VOICE_DAVE_PREPARE_TRANSITION, + self::VOICE_DAVE_EXECUTE_TRANSITION, + self::VOICE_DAVE_TRANSITION_READY, + self::VOICE_DAVE_PREPARE_EPOCH, + self::VOICE_DAVE_MLS_EXTERNAL_SENDER, + self::VOICE_DAVE_MLS_KEY_PACKAGE, + self::VOICE_DAVE_MLS_PROPOSALS, + self::VOICE_DAVE_MLS_COMMIT_WELCOME, + self::VOICE_DAVE_MLS_ANNOUNCE_COMMIT_TRANSITION, + self::VOICE_DAVE_MLS_WELCOME, + self::VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME, + ]; + } + + public static function getGatewayCodes(): array + { + return [ + self::OP_DISPATCH, + self::OP_HEARTBEAT, + self::OP_IDENTIFY, + self::OP_PRESENCE_UPDATE, + self::OP_VOICE_STATE_UPDATE, + self::OP_VOICE_SERVER_PING, + self::OP_RESUME, + self::OP_RECONNECT, + self::OP_GUILD_MEMBER_CHUNK, + self::OP_INVALID_SESSION, + self::OP_HELLO, + self::OP_HEARTBEAT_ACK, + self::REQUEST_SOUNDBOARD_SOUNDS, + ]; + } + + public static function getAllCodes(): array + { + return array_merge( + self::getGatewayCodes(), + self::getVoiceCodes() + ); + } + + public static function isVoiceCode(int $code): bool + { + return in_array($code, self::getVoiceCodes(), true); + } + + public static function isGatewayCode(int $code): bool + { + return in_array($code, self::getGatewayCodes(), true); + } + + public static function isValidCode(int $code): bool + { + return in_array($code, self::getAllCodes(), true); + } + + public static function isCriticalCloseCode(int $code): bool + { + return in_array($code, self::getCriticalCloseCodes(), true); + } + + public static function isCriticalVoiceCloseCode(int $code): bool + { + return in_array($code, self::getCriticalVoiceCloseCodes(), true); + } + + public static function isValidOpCode(int $code): bool + { + return self::isGatewayCode($code) || self::isVoiceCode($code); + } + + public static function isValidCloseCode(int $code): bool + { + return self::isCriticalCloseCode($code) || self::isCriticalVoiceCloseCode($code); + } + + public static function isValidOp(int $code): bool + { + return self::isValidOpCode($code) || self::isValidCloseCode($code); + } + + public static function voiceCodeToString( + ?self $code = null, + bool $snakeCase = false, + bool $pluckVoicePrefix = true + ): string + { + $code ??= $code?->value; + if (!$code instanceof self && !self::isVoiceCode($code)) { + return ''; + } + $name = self::from($code)->name; + + if ($pluckVoicePrefix) { + $name = str_replace('VOICE_', '', $name); + } + + if ($snakeCase) { + return strtolower(preg_replace('/(? Date: Wed, 18 Jun 2025 18:39:54 +0100 Subject: [PATCH 097/121] Updates class names --- src/Discord/Voice/Client/Ws.php | 2 +- src/Discord/Voice/Processes/Dca.php | 2 +- src/Discord/Voice/VoiceClient.php | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Discord/Voice/Client/Ws.php b/src/Discord/Voice/Client/Ws.php index 5720edeed..7eb2f2841 100644 --- a/src/Discord/Voice/Client/Ws.php +++ b/src/Discord/Voice/Client/Ws.php @@ -18,7 +18,7 @@ use React\Dns\Resolver\Factory as DnsFactory; use React\Promise\PromiseInterface; -final class Ws +final class WS { protected static VoiceClient $vc; diff --git a/src/Discord/Voice/Processes/Dca.php b/src/Discord/Voice/Processes/Dca.php index e2d747f42..177715841 100644 --- a/src/Discord/Voice/Processes/Dca.php +++ b/src/Discord/Voice/Processes/Dca.php @@ -5,7 +5,7 @@ use Discord\Voice\Processes\ProcessAbstract; use React\ChildProcess\Process; -final class Dca extends ProcessAbstract +final class DCA extends ProcessAbstract { /** * The DCA version the client is using. diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 842e5eb35..806eca7b0 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -28,7 +28,7 @@ use Discord\Parts\Voice\UserConnected; use Discord\Voice\Client\Packet; use Discord\Voice\Client\User; -use Discord\Voice\Client\Ws; +use Discord\Voice\Client\WS; use Discord\Voice\Processes\Dca; use Discord\Voice\Processes\Ffmpeg; use Discord\Voice\ReceiveStream; @@ -402,7 +402,7 @@ public function start(): bool return false; } - Ws::make($this); + WS::make($this); return true; } From 8e3e4ca8e73dc97f59332adbc03373c870974a49 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Wed, 18 Jun 2025 18:40:39 +0100 Subject: [PATCH 098/121] Renames --- src/Discord/Voice/Client/{Ws.php => WS.php} | 0 src/Discord/Voice/Processes/{Dca.php => DCA.php} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/Discord/Voice/Client/{Ws.php => WS.php} (100%) rename src/Discord/Voice/Processes/{Dca.php => DCA.php} (100%) diff --git a/src/Discord/Voice/Client/Ws.php b/src/Discord/Voice/Client/WS.php similarity index 100% rename from src/Discord/Voice/Client/Ws.php rename to src/Discord/Voice/Client/WS.php diff --git a/src/Discord/Voice/Processes/Dca.php b/src/Discord/Voice/Processes/DCA.php similarity index 100% rename from src/Discord/Voice/Processes/Dca.php rename to src/Discord/Voice/Processes/DCA.php From 14f952743e973925ce6dddc3b157745a4d6a32a6 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Thu, 3 Jul 2025 15:14:16 +0100 Subject: [PATCH 099/121] Adds some declare strict types Updates WS class to non-static version --- src/Discord/Discord.php | 2 +- src/Discord/Voice/Client/HeaderValuesEnum.php | 2 + src/Discord/Voice/Client/User.php | 2 + src/Discord/Voice/Client/WS.php | 314 +++++++++--------- src/Discord/Voice/Processes/DCA.php | 2 + src/Discord/Voice/Processes/Ffmpeg.php | 2 + .../Voice/Processes/ProcessAbstract.php | 2 + 7 files changed, 165 insertions(+), 161 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index d2a0d7d8f..7fde05297 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -129,7 +129,7 @@ class Discord * * @var LoopInterface Event loop. */ - protected $loop; + public static $loop; /** * The WebSocket client factory. diff --git a/src/Discord/Voice/Client/HeaderValuesEnum.php b/src/Discord/Voice/Client/HeaderValuesEnum.php index cbd20204e..cb1469905 100644 --- a/src/Discord/Voice/Client/HeaderValuesEnum.php +++ b/src/Discord/Voice/Client/HeaderValuesEnum.php @@ -1,5 +1,7 @@ $data ??= $this->vc->data; + $this->bot ??= $this->vc->bot; - if (! isset($data)) { - self::$data = $vc->data; - } - - if (! $bot) { - self::$bot = $vc->bot; - } - - $f = new Connector(self::$bot->loop); + $f = new Connector($this->bot->loop); /** @var PromiseInterface */ - $f("wss://" . self::$data['endpoint'] . "?v=" . self::$version) + $f("wss://" . $this->data['endpoint'] . "?v=" . self::$version) ->then( - static fn (WebSocket $ws) => self::handleConnection($ws), - static fn (\Throwable $e) => self::$bot->logger->error( + fn (WebSocket $ws) => $this->handleConnection($ws), + fn (\Throwable $e) => $this->bot->logger->error( 'Failed to connect to voice gateway: {error}', ['error' => $e->getMessage()] - ) && self::$vc->emit('error', [$e]) + ) && $this->vc->emit('error', [$e]) ); } @@ -79,20 +74,20 @@ public static function make( * * @param WebSocket $ws The WebSocket instance. */ - public static function handleConnection(WebSocket $ws): void + public function handleConnection(WebSocket $ws): void { - self::$bot->logger->debug('connected to voice websocket'); + $this->bot->logger->debug('connected to voice websocket'); - $resolver = (new DnsFactory())->createCached(self::$data['dnsConfig'], self::$bot->loop); - $udpfac = new Factory(self::$bot->loop, $resolver); + $resolver = (new DnsFactory())->createCached($this->data['dnsConfig'], $this->bot->loop); + $udpfac = new Factory($this->bot->loop, $resolver); - self::$socket = self::$vc->voiceWebsocket = $ws; + $this->socket = $this->vc->voiceWebsocket = $ws; $ip = $port = ''; - $ws->on('message', function (Message $message) use ($udpfac, &$ip, &$port): void { + $ws->on('message', function (Message $message) use ($udpfac): void { $data = json_decode($message->getPayload()); - self::$vc->emit('ws-message', [$message, self::$vc]); + $this->vc->emit('ws-message', [$message, $this->vc]); switch ($data->op) { case Op::VOICE_HEARTBEAT_ACK: // keepalive response @@ -100,171 +95,171 @@ public static function handleConnection(WebSocket $ws): void $start = $data->d->t; $diff = ($end - $start) * 1000; - self::$bot->logger->debug('received heartbeat ack', ['response_time' => $diff]); - self::$vc->emit('ws-ping', [$diff]); - self::$vc->emit('ws-heartbeat-ack', [$data->d->t]); + $this->bot->logger->debug('received heartbeat ack', ['response_time' => $diff]); + $this->vc->emit('ws-ping', [$diff]); + $this->vc->emit('ws-heartbeat-ack', [$data->d->t]); break; case Op::VOICE_DESCRIPTION: // ready - self::$vc->ready = true; - self::$vc->mode = $data->d->mode; - self::$vc->secretKey = ''; - self::$vc->rawKey = $data->d->secret_key; - self::$vc->secretKey = implode('', array_map(static fn ($value) => pack('C', $value), self::$vc->rawKey)); + $this->vc->ready = true; + $this->vc->mode = $data->d->mode; + $this->vc->secretKey = ''; + $this->vc->rawKey = $data->d->secret_key; + $this->vc->secretKey = implode('', array_map(static fn ($value) => pack('C', $value), $this->vc->rawKey)); - self::$bot->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); - if (! self::$vc->reconnecting) { - self::$vc->emit('ready', [self::$vc]); + if (! $this->vc->reconnecting) { + $this->vc->emit('ready', [$this->vc]); } else { - self::$vc->reconnecting = false; - self::$vc->emit('resumed', [self::$vc]); + $this->vc->reconnecting = false; + $this->vc->emit('resumed', [$this->vc]); } - if (! self::$vc->deaf && self::$vc->secretKey) { - self::$vc->client->on( + if (! $this->vc->deaf && $this->vc->secretKey) { + $this->vc->client->on( 'message', - fn (string $message) => self::$vc->handleAudioData(new Packet( + fn (string $message) => $this->vc->handleAudioData(new Packet( $message, - key: self::$vc->secretKey, - log: self::$bot->logger + key: $this->vc->secretKey, + log: $this->bot->logger ))); } break; case Op::VOICE_SPEAKING: // currently connected users - self::$bot->logger->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); - self::$vc->emit('speaking', [$data->d->speaking, $data->d->user_id, self::$vc]); - self::$vc->emit("speaking.{$data->d->user_id}", [$data->d->speaking, self::$vc]); - self::$vc->speakingStatus[$data->d->user_id] = self::$bot->getFactory()->create(VoiceSpeaking::class, $data->d); + $this->bot->logger->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->vc->emit('speaking', [$data->d->speaking, $data->d->user_id, $this->vc]); + $this->vc->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this->vc]); + $this->vc->speakingStatus[$data->d->user_id] = $this->bot->getFactory()->create(VoiceSpeaking::class, $data->d); break; case Op::VOICE_HELLO: - self::$vc->heartbeatInterval = $data->d->heartbeat_interval; - self::sendHeartbeat(); - self::$vc->heartbeat = self::$bot->loop->addPeriodicTimer(self::$vc->heartbeatInterval / 1000, fn () => self::sendHeartbeat()); + $this->vc->heartbeatInterval = $data->d->heartbeat_interval; + $this->sendHeartbeat(); + $this->vc->heartbeat = $this->bot->loop->addPeriodicTimer($this->vc->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); break; case Op::VOICE_CLIENTS_CONNECT: - self::$bot->logger->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); # "d" contains an array with ['user_ids' => array] - self::$vc->users = array_map(fn (int $userId) => self::$bot->getFactory()->create(UserConnected::class, $userId), $data->d->user_ids); + $this->vc->users = array_map(fn (int $userId) => $this->bot->getFactory()->create(UserConnected::class, $userId), $data->d->user_ids); break; case Op::VOICE_CLIENT_DISCONNECT: - self::$bot->logger->debug('received client disconnected packet', ['data' => json_decode(json_encode($data->d), true)]); - unset(self::$vc->clientsConnected[$data->d->user_id]); + $this->bot->logger->debug('received client disconnected packet', ['data' => json_decode(json_encode($data->d), true)]); + unset($this->vc->clientsConnected[$data->d->user_id]); break; case Op::VOICE_CLIENT_UNKNOWN_15: case Op::VOICE_CLIENT_UNKNOWN_18: - self::$bot->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + $this->bot->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); break; case Op::VOICE_CLIENT_PLATFORM: - self::$bot->logger->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); # handlePlatformPerUser # platform = 0 assumed to be Desktop break; case Op::VOICE_DAVE_PREPARE_TRANSITION: - #$this->handleDavePrepareTransition($data); + $this->handleDavePrepareTransition($data); break; case Op::VOICE_DAVE_EXECUTE_TRANSITION: - #$this->handleDaveExecuteTransition($data); + $this->handleDaveExecuteTransition($data); break; case Op::VOICE_DAVE_TRANSITION_READY: - #$this->handleDaveTransitionReady($data); + $this->handleDaveTransitionReady($data); break; case Op::VOICE_DAVE_PREPARE_EPOCH: - #$this->handleDavePrepareEpoch($data); + $this->handleDavePrepareEpoch($data); break; case Op::VOICE_DAVE_MLS_EXTERNAL_SENDER: - #$this->handleDaveMlsExternalSender($data); + $this->handleDaveMlsExternalSender($data); break; case Op::VOICE_DAVE_MLS_KEY_PACKAGE: - #$this->handleDaveMlsKeyPackage($data); + $this->handleDaveMlsKeyPackage($data); break; case Op::VOICE_DAVE_MLS_PROPOSALS: - #$this->handleDaveMlsProposals($data); + $this->handleDaveMlsProposals($data); break; case Op::VOICE_DAVE_MLS_COMMIT_WELCOME: - #$this->handleDaveMlsCommitWelcome($data); + $this->handleDaveMlsCommitWelcome($data); break; case Op::VOICE_DAVE_MLS_ANNOUNCE_COMMIT_TRANSITION: - #$this->handleDaveMlsAnnounceCommitTransition($data); + $this->handleDaveMlsAnnounceCommitTransition($data); break; case Op::VOICE_DAVE_MLS_WELCOME: - #$this->handleDaveMlsWelcome($data); + $this->handleDaveMlsWelcome($data); break; case Op::VOICE_DAVE_MLS_INVALID_COMMIT_WELCOME: - #$this->handleDaveMlsInvalidCommitWelcome($data); + $this->handleDaveMlsInvalidCommitWelcome($data); break; case Op::VOICE_READY: { - self::$vc->udpPort = $data->d->port; - self::$vc->ssrc = $data->d->ssrc; + $this->vc->udpPort = $data->d->port; + $this->vc->ssrc = $data->d->ssrc; - self::$bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); $buffer = new Buffer(74); $buffer[1] = "\x01"; $buffer[3] = "\x46"; - $buffer->writeUInt32BE(self::$vc->ssrc, 4); + $buffer->writeUInt32BE($this->vc->ssrc, 4); /** @var PromiseInterface */ - $udpfac->createClient("{$data->d->ip}:" . self::$vc->udpPort)->then(function (Socket $client) use (&$ip, &$port, $buffer): void { - self::$bot->logger->debug('connected to voice UDP'); - self::$vc->client = $client; + $udpfac->createClient("{$data->d->ip}:" . $this->vc->udpPort)->then(function (Socket $client) use (&$ip, &$port, $buffer): void { + $this->bot->logger->debug('connected to voice UDP'); + $this->vc->client = $client; - self::$bot->loop->addTimer(0.1, fn () => self::$vc->client->send($buffer->__toString())); + $this->bot->loop->addTimer(0.1, fn () => $this->vc->client->send($buffer->__toString())); - self::$vc->udpHeartbeat = self::$bot->loop->addPeriodicTimer(self::$vc->heartbeatInterval / 1000, function (): void { + $this->vc->udpHeartbeat = $this->bot->loop->addPeriodicTimer($this->vc->heartbeatInterval / 1000, function (): void { $buffer = new Buffer(9); $buffer[0] = 0xC9; - $buffer->writeUInt64LE(self::$vc->heartbeatSeq, 1); - ++self::$vc->heartbeatSeq; + $buffer->writeUInt64LE($this->vc->heartbeatSeq, 1); + ++$this->vc->heartbeatSeq; - self::$vc->client->send($buffer->__toString()); - self::$vc->emit('udp-heartbeat', []); + $this->vc->client->send($buffer->__toString()); + $this->vc->emit('udp-heartbeat', []); - self::$bot->logger->debug('sent UDP heartbeat'); + $this->bot->logger->debug('sent UDP heartbeat'); }); - $client->on('error', fn ($e) => self::$vc->emit('udp-error', [$e])); + $client->on('error', fn ($e) => $this->vc->emit('udp-error', [$e])); - #$client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); + $client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); }, function (\Throwable $e): void { - self::$bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); - self::$vc->emit('error', [$e]); + $this->bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); + $this->vc->emit('error', [$e]); }); break; } default: - self::$bot->logger->warning('Unknown opcode.', $data); + $this->bot->logger->warning('Unknown opcode.', $data); break; } }); $ws->on('error', function ($e): void { - self::$bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); - self::$vc->emit('ws-error', [$e]); + $this->bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); + $this->vc->emit('ws-error', [$e]); }); - //$ws->on('close', [$this, 'handleClose']); + $ws->on('close', [$this, 'handleClose']); - if (self::$vc->sentLoginFrame) { + if ($this->vc->sentLoginFrame) { return; } $payload = VoicePayload::new( Op::VOICE_IDENTIFY, [ - 'server_id' => self::$vc->channel->guild_id, - 'user_id' => self::$data['user_id'], - 'session_id' => self::$data['session'], - 'token' => self::$data['token'], + 'server_id' => $this->vc->channel->guild_id, + 'user_id' => $this->data['user_id'], + 'session_id' => $this->data['session'], + 'token' => $this->data['token'], ], ); - self::$bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); + $this->bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); - self::send($payload); - self::$vc->sentLoginFrame = true; + $this->send($payload); + $this->vc->sentLoginFrame = true; } /** @@ -272,10 +267,10 @@ public static function handleConnection(WebSocket $ws): void * * @param VoicePayload|array $data The data to send to the voice WebSocket. */ - public static function send(VoicePayload|array $data): void + public function send(VoicePayload|array $data): void { $json = json_encode($data); - self::$socket->send($json); + $this->socket->send($json); } /** @@ -285,38 +280,38 @@ public static function send(VoicePayload|array $data): void * @param object $ss The speaking status object * @param callable $createDecoder Function to create a new decoder if needed */ - /* protected function monitorProcessExit(Process $process, $ss): void + protected function monitorProcessExit(Process $process, $ss): void { // Store the process ID // $pid = $process->getPid(); // Check every second if the process is still running - self::$monitorProcessTimer = self::$bot->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { + $this->monitorProcessTimer = $this->bot->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { // Check if the process is still running if (!$process->isRunning()) { // Get the exit code $exitCode = $process->getExitCode(); // Clean up the timer - self::$bot->loop->cancelTimer($this->monitorProcessTimer); + $this->bot->loop->cancelTimer($this->monitorProcessTimer); // If exit code indicates an error, emit event and recreate decoder if ($exitCode > 0) { - $this->emit('decoder-error', [$exitCode, null, $ss]); - $this->createDecoder($ss); + $this->vc->emit('decoder-error', [$exitCode, null, $ss]); + //$this->createDecoder($ss); } // Clean up temporary files // $this->cleanupTempFiles(); } }); - } */ + } - protected static function handleDavePrepareTransition($data) + protected function handleDavePrepareTransition($data) { - self::$bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition - self::send(VoicePayload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_TRANSITION_READY, [ 'transition_id' => $data->d->transition_id, @@ -324,24 +319,24 @@ protected static function handleDavePrepareTransition($data) )); } - protected static function handleDaveExecuteTransition($data) + protected function handleDaveExecuteTransition($data) { - self::$bot->logger->debug('DAVE Execute Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE Execute Transition', ['data' => $data]); // Execute the transition // Update local state to reflect the new protocol context } - protected static function handleDaveTransitionReady($data) + protected function handleDaveTransitionReady($data) { - self::$bot->logger->debug('DAVE Transition Ready', ['data' => $data]); + $this->bot->logger->debug('DAVE Transition Ready', ['data' => $data]); // Handle transition ready state } - protected static function handleDavePrepareEpoch($data) + protected function handleDavePrepareEpoch($data) { - self::$bot->logger->debug('DAVE Prepare Epoch', ['data' => $data]); + $this->bot->logger->debug('DAVE Prepare Epoch', ['data' => $data]); // Prepare local MLS group with parameters appropriate for the DAVE protocol version - self::send(VoicePayload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, [ 'epoch_id' => $data->d->epoch_id, @@ -350,23 +345,23 @@ protected static function handleDavePrepareEpoch($data) )); } - protected static function handleDaveMlsExternalSender($data) + protected function handleDaveMlsExternalSender($data) { - self::$bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); // Handle external sender public key and credential } - protected static function handleDaveMlsKeyPackage($data) + protected function handleDaveMlsKeyPackage($data) { - self::$bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); // Handle MLS key package } - protected static function handleDaveMlsProposals($data) + protected function handleDaveMlsProposals($data) { - self::$bot->logger->debug('DAVE MLS Proposals', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Proposals', ['data' => $data]); // Handle MLS proposals - self::send(VoicePayload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_COMMIT_WELCOME, [ //'commit' => $this->generateCommit(), @@ -375,30 +370,30 @@ protected static function handleDaveMlsProposals($data) )); } - protected static function handleDaveMlsCommitWelcome($data) + protected function handleDaveMlsCommitWelcome($data) { - self::$bot->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); // Handle MLS commit and welcome messages } - protected static function handleDaveMlsAnnounceCommitTransition($data) + protected function handleDaveMlsAnnounceCommitTransition($data) { // Handle MLS announce commit transition - self::$bot->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); } - protected static function handleDaveMlsWelcome($data) + protected function handleDaveMlsWelcome($data) { // Handle MLS welcome message - self::$bot->logger->debug('DAVE MLS Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Welcome', ['data' => $data]); } - protected static function handleDaveMlsInvalidCommitWelcome($data) + protected function handleDaveMlsInvalidCommitWelcome($data) { - self::$bot->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); // Handle invalid commit or welcome message // Reset local group state and generate a new key package - self::send(VoicePayload::new( + $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, [ //'key_package' => $this->generateKeyPackage(), @@ -406,8 +401,8 @@ protected static function handleDaveMlsInvalidCommitWelcome($data) )); } - #protected function decodeUDP($message, string &$ip, string &$port): void - #{ + protected function decodeUDP($message, string &$ip, string &$port): void + { /** * Unpacks the message into an array. * @@ -421,7 +416,7 @@ protected static function handleDaveMlsInvalidCommitWelcome($data) * @see https://www.php.net/manual/en/function.unpack.php * @see https://www.php.net/manual/en/function.pack.php For the formats */ - /* $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); + $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); $this->ssrc = $unpackedMessageArray['SSRC']; $ip = $unpackedMessageArray['Address']; @@ -439,40 +434,39 @@ protected static function handleDaveMlsInvalidCommitWelcome($data) 'mode' => $this->mode, ], ], - ]); */ - #} + ]); + } - public static function sendHeartbeat(): void + public function sendHeartbeat(): void { - self::send(VoicePayload::new( + $this->send(VoicePayload::new( Op::VOICE_HEARTBEAT, [ 't' => (int) microtime(true), 'seq_ack' => 10, ] )); - self::$bot->logger->debug('sending heartbeat'); - self::$vc->emit('ws-heartbeat', []); + $this->bot->logger->debug('sending heartbeat'); + $this->vc->emit('ws-heartbeat', []); } - // TODO still need to convert to static - public static function handleClose(int $op, string $reason): void + public function handleClose(int $op, string $reason): void { $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); - $this->emit('ws-close', [$op, $reason, $this]); + $this->vc->emit('ws-close', [$op, $reason, $this]); $this->clientsConnected = []; - $this->voiceWebsocket->close(); + $this->socket->close(); // Cancel heartbeat timers - if (null !== $this->heartbeat) { - $this->bot->loop->cancelTimer($this->heartbeat); + if (null !== $this->vc->heartbeat) { + $this->bot->loop->cancelTimer($this->vc->heartbeat); $this->heartbeat = null; } - if (null !== $this->udpHeartbeat) { - $this->bot->loop->cancelTimer($this->udpHeartbeat); - $this->udpHeartbeat = null; + if (null !== $this->vc->udpHeartbeat) { + $this->bot->loop->cancelTimer($this->vc->udpHeartbeat); + $this->vc->udpHeartbeat = null; } // Close UDP socket. @@ -482,15 +476,15 @@ public static function handleClose(int $op, string $reason): void } // Don't reconnect on a critical opcode or if closed by user. - if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this->userClose) { + if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this?->userClose) { $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); - $this->emit('close'); + $this->vc->emit('close'); return; } if (in_array($op, [Op::CLOSE_VOICE_DISCONNECTED])) { - $this->emit('close'); + $this->vc->emit('close'); return; } @@ -502,7 +496,7 @@ public static function handleClose(int $op, string $reason): void $this->reconnecting = true; $this->sentLoginFrame = false; - $this->start(); + $this->vc->start(); }); } } diff --git a/src/Discord/Voice/Processes/DCA.php b/src/Discord/Voice/Processes/DCA.php index 177715841..5275e6648 100644 --- a/src/Discord/Voice/Processes/DCA.php +++ b/src/Discord/Voice/Processes/DCA.php @@ -1,5 +1,7 @@ Date: Thu, 3 Jul 2025 18:13:14 +0100 Subject: [PATCH 100/121] Adds static instance for discord class, to be able to call a helper function anywhere in the project Adds new UDP client, to handle the events from the udp voice client Updates usages to fix the issues created with previous changes --- src/Discord/Discord.php | 30 +++++- src/Discord/Factory/SocketFactory.php | 41 ++++++++ src/Discord/Voice/Client/Packet.php | 5 + src/Discord/Voice/Client/UDP.php | 105 ++++++++++++++++++++ src/Discord/Voice/Client/WS.php | 135 ++++++++++++-------------- src/Discord/Voice/VoiceClient.php | 91 +---------------- src/Discord/functions.php | 16 +++ 7 files changed, 262 insertions(+), 161 deletions(-) create mode 100644 src/Discord/Factory/SocketFactory.php create mode 100644 src/Discord/Voice/Client/UDP.php diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 7fde05297..97fd7c7e7 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -129,7 +129,7 @@ class Discord * * @var LoopInterface Event loop. */ - public static $loop; + public $loop; /** * The WebSocket client factory. @@ -356,6 +356,13 @@ class Discord */ protected $usePayloadCompression; + /** + * The instance of the Discord client. + * + * @var Discord|null Instance. + */ + protected static Discord $instance; + /** * Creates a Discord client instance. * @@ -410,6 +417,22 @@ public function __construct(array $options = []) $this->useTransportCompression = $options['useTransportCompression']; $this->usePayloadCompression = $options['usePayloadCompression']; $this->connectWs(); + + if (!isset(self::$instance)) { + // If the instance is not set, set it to this instance. + // This allows for static access to the Discord client. + self::$instance = $this; + } + } + + # BETA - still testing if it works + public static function __callStatic($method, $args) + { + if (method_exists(self::class, $method)) { + return self::$instance->$method(...$args); + } + + throw new \BadMethodCallException("Method {$method} does not exist in " . __CLASS__); } /** @@ -1723,4 +1746,9 @@ public function getWs(): ?WebSocket { return $this->ws; } + + public static function getInstance(): ?self + { + return self::$instance; + } } diff --git a/src/Discord/Factory/SocketFactory.php b/src/Discord/Factory/SocketFactory.php new file mode 100644 index 000000000..7edc74e64 --- /dev/null +++ b/src/Discord/Factory/SocketFactory.php @@ -0,0 +1,41 @@ +ws = $ws; + } + } + + public function createClient($address) + { + $loop = $this->loop; + + return $this->resolveAddress($address)->then(function ($address) use ($loop) { + $socket = @\stream_socket_client($address, $errno, $errstr); + if (!$socket) { + throw new \Exception('Unable to create client socket: ' . $errstr, $errno); + } + + return new UDP($loop, $socket, ws: $this?->ws); + }); + } +} diff --git a/src/Discord/Voice/Client/Packet.php b/src/Discord/Voice/Client/Packet.php index fbf2ba221..42fddee5c 100644 --- a/src/Discord/Voice/Client/Packet.php +++ b/src/Discord/Voice/Client/Packet.php @@ -16,6 +16,7 @@ use Discord\Helpers\ByteBuffer\Buffer; use Discord\Helpers\FormatPackEnum; use Monolog\Logger; +use function Discord\logger; /** * A voice packet received from Discord. @@ -117,6 +118,10 @@ public function __construct( if ($decrypt) { $this->decrypt(); } + + if (!$log) { + $this->log = logger(); + } } /** diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php new file mode 100644 index 000000000..7adb24a3f --- /dev/null +++ b/src/Discord/Voice/Client/UDP.php @@ -0,0 +1,105 @@ +ws = $ws; + } + } + + public function handleMessages(VoiceClient $vc, $secret): self + { + return $this->on('message', static fn (string $message) => $vc->handleAudioData( + new Packet($message, key: $secret) + )); + } + + public function handleSsrcSending(): self + { + $buffer = new Buffer(74); + $buffer[1] = "\x01"; + $buffer[3] = "\x46"; + $buffer->writeUInt32BE($this->ws->vc->ssrc, 4); + loop()->addTimer(0.1, fn () => $this->ws->vc->client->send($buffer->__toString())); + + return $this; + } + + public function handleHeartbeat(): self + { + $this->ws->vc->udpHeartbeat = loop()->addPeriodicTimer($this->ws->vc->heartbeatInterval / 1000, function (): void { + $buffer = new Buffer(9); + $buffer[0] = 0xC9; + $buffer->writeUInt64LE($this->ws->vc->heartbeatSeq, 1); + ++$this->ws->vc->heartbeatSeq; + + $this->ws->vc->client->send($buffer->__toString()); + $this->ws->vc->emit('udp-heartbeat', []); + + logger()->debug('sent UDP heartbeat'); + }); + + return $this; + } + + public function decodeOnce(): self + { + return $this->once('message', function (string $message) { + /** + * Unpacks the message into an array. + * + * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively + * n (unsigned short) | Length | 2 bytes | Length of the following data + * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender + * A64 (string) | Address | 64 bytes | The IP address of the sender + * n (unsigned short) | Port | 2 bytes | The port of the sender + * + * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery + * @see https://www.php.net/manual/en/function.unpack.php + * @see https://www.php.net/manual/en/function.pack.php For the formats + */ + $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); + + $this->ws->vc->ssrc = $unpackedMessageArray['SSRC']; + $ip = $unpackedMessageArray['Address']; + $port = $unpackedMessageArray['Port']; + + logger()->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + + $this->ws->send([ + 'op' => Op::VOICE_SELECT_PROTO, + 'd' => [ + 'protocol' => 'udp', + 'data' => [ + 'address' => $ip, + 'port' => $port, + 'mode' => $this->ws->mode, + ], + ], + ]); + }); + } + + public function handleErrors(): self + { + return $this->on('error', function (\Throwable $e): void { + logger()->error('UDP error', ['e' => $e->getMessage()]); + $this->ws->vc->emit('udp-error', [$e]); + }); + } +} diff --git a/src/Discord/Voice/Client/WS.php b/src/Discord/Voice/Client/WS.php index 54909467a..6c4413351 100644 --- a/src/Discord/Voice/Client/WS.php +++ b/src/Discord/Voice/Client/WS.php @@ -5,6 +5,7 @@ namespace Discord\Voice\Client; use Discord\Discord; +use Discord\Factory\SocketFactory; use Discord\Helpers\ByteBuffer\Buffer; use Discord\Parts\EventData\VoiceSpeaking; use Discord\Parts\Voice\UserConnected; @@ -19,6 +20,7 @@ use React\Datagram\Socket; use React\Dns\Resolver\Factory as DnsFactory; use React\Promise\PromiseInterface; +use function Discord\logger; final class WS { @@ -40,12 +42,28 @@ final class WS */ public string $mode = 'aead_aes256_gcm_rtpsize'; + /** + * The secret key used for encrypting voice. + * + * @var string|null The secret key. + */ + public $secretKey; + + /** + * The raw secret key. + * + * @var array|null The raw secret key. + */ + public $rawKey; + + public $ssrc; + public function __construct( - protected VoiceClient $vc, + public VoiceClient $vc, protected ?Discord $bot = null, protected ?array $data = [], ) { - $this->$data ??= $this->vc->data; + $this->data ??= $this->vc->data; $this->bot ??= $this->vc->bot; $f = new Connector($this->bot->loop); @@ -54,7 +72,7 @@ public function __construct( $f("wss://" . $this->data['endpoint'] . "?v=" . self::$version) ->then( fn (WebSocket $ws) => $this->handleConnection($ws), - fn (\Throwable $e) => $this->bot->logger->error( + fn (\Throwable $e) => logger()->error( 'Failed to connect to voice gateway: {error}', ['error' => $e->getMessage()] ) && $this->vc->emit('error', [$e]) @@ -76,10 +94,10 @@ public static function make( */ public function handleConnection(WebSocket $ws): void { - $this->bot->logger->debug('connected to voice websocket'); + logger()->debug('connected to voice websocket'); $resolver = (new DnsFactory())->createCached($this->data['dnsConfig'], $this->bot->loop); - $udpfac = new Factory($this->bot->loop, $resolver); + $udpfac = new SocketFactory($this->bot->loop, $resolver, $this); $this->socket = $this->vc->voiceWebsocket = $ws; @@ -95,18 +113,18 @@ public function handleConnection(WebSocket $ws): void $start = $data->d->t; $diff = ($end - $start) * 1000; - $this->bot->logger->debug('received heartbeat ack', ['response_time' => $diff]); + logger()->debug('received heartbeat ack', ['response_time' => $diff]); $this->vc->emit('ws-ping', [$diff]); $this->vc->emit('ws-heartbeat-ack', [$data->d->t]); break; case Op::VOICE_DESCRIPTION: // ready $this->vc->ready = true; - $this->vc->mode = $data->d->mode; - $this->vc->secretKey = ''; - $this->vc->rawKey = $data->d->secret_key; - $this->vc->secretKey = implode('', array_map(static fn ($value) => pack('C', $value), $this->vc->rawKey)); + $this->mode = $data->d->mode === $this->mode ? $this->mode : 'aead_aes256_gcm_rtpsize'; + $this->secretKey = ''; + $this->rawKey = $data->d->secret_key; + $this->secretKey = implode('', array_map(static fn ($value) => pack('C', $value), $this->rawKey)); - $this->bot->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); + logger()->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); if (! $this->vc->reconnecting) { $this->vc->emit('ready', [$this->vc]); @@ -115,19 +133,13 @@ public function handleConnection(WebSocket $ws): void $this->vc->emit('resumed', [$this->vc]); } - if (! $this->vc->deaf && $this->vc->secretKey) { - $this->vc->client->on( - 'message', - fn (string $message) => $this->vc->handleAudioData(new Packet( - $message, - key: $this->vc->secretKey, - log: $this->bot->logger - ))); + if (! $this->vc->deaf && $this->secretKey) { + $this->vc->client->handleMessages($this->vc, $this->secretKey); } break; case Op::VOICE_SPEAKING: // currently connected users - $this->bot->logger->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); + logger()->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); $this->vc->emit('speaking', [$data->d->speaking, $data->d->user_id, $this->vc]); $this->vc->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this->vc]); $this->vc->speakingStatus[$data->d->user_id] = $this->bot->getFactory()->create(VoiceSpeaking::class, $data->d); @@ -138,21 +150,21 @@ public function handleConnection(WebSocket $ws): void $this->vc->heartbeat = $this->bot->loop->addPeriodicTimer($this->vc->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); break; case Op::VOICE_CLIENTS_CONNECT: - $this->bot->logger->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); + logger()->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); # "d" contains an array with ['user_ids' => array] $this->vc->users = array_map(fn (int $userId) => $this->bot->getFactory()->create(UserConnected::class, $userId), $data->d->user_ids); break; case Op::VOICE_CLIENT_DISCONNECT: - $this->bot->logger->debug('received client disconnected packet', ['data' => json_decode(json_encode($data->d), true)]); + logger()->debug('received client disconnected packet', ['data' => json_decode(json_encode($data->d), true)]); unset($this->vc->clientsConnected[$data->d->user_id]); break; case Op::VOICE_CLIENT_UNKNOWN_15: case Op::VOICE_CLIENT_UNKNOWN_18: - $this->bot->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + logger()->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); break; case Op::VOICE_CLIENT_PLATFORM: - $this->bot->logger->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); + logger()->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); # handlePlatformPerUser # platform = 0 assumed to be Desktop break; @@ -194,48 +206,29 @@ public function handleConnection(WebSocket $ws): void $this->vc->udpPort = $data->d->port; $this->vc->ssrc = $data->d->ssrc; - $this->bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + logger()->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); - $buffer = new Buffer(74); - $buffer[1] = "\x01"; - $buffer[3] = "\x46"; - $buffer->writeUInt32BE($this->vc->ssrc, 4); /** @var PromiseInterface */ - $udpfac->createClient("{$data->d->ip}:" . $this->vc->udpPort)->then(function (Socket $client) use (&$ip, &$port, $buffer): void { - $this->bot->logger->debug('connected to voice UDP'); + $udpfac->createClient("{$data->d->ip}:" . $this->vc->udpPort)->then(function (UDP $client): void { $this->vc->client = $client; - - $this->bot->loop->addTimer(0.1, fn () => $this->vc->client->send($buffer->__toString())); - - $this->vc->udpHeartbeat = $this->bot->loop->addPeriodicTimer($this->vc->heartbeatInterval / 1000, function (): void { - $buffer = new Buffer(9); - $buffer[0] = 0xC9; - $buffer->writeUInt64LE($this->vc->heartbeatSeq, 1); - ++$this->vc->heartbeatSeq; - - $this->vc->client->send($buffer->__toString()); - $this->vc->emit('udp-heartbeat', []); - - $this->bot->logger->debug('sent UDP heartbeat'); - }); - - $client->on('error', fn ($e) => $this->vc->emit('udp-error', [$e])); - - $client->once('message', fn ($message) => $this->decodeUDP($message, $ip, $port)); + $client->handleSsrcSending() + ->handleHeartbeat() + ->handleErrors() + ->decodeOnce(); }, function (\Throwable $e): void { - $this->bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); + logger()->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->vc->emit('error', [$e]); }); break; } default: - $this->bot->logger->warning('Unknown opcode.', $data); + logger()->warning('Unknown opcode.', $data); break; } }); $ws->on('error', function ($e): void { - $this->bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); + logger()->error('error with voice websocket', ['e' => $e->getMessage()]); $this->vc->emit('ws-error', [$e]); }); @@ -256,7 +249,7 @@ public function handleConnection(WebSocket $ws): void ], ); - $this->bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); + logger()->debug('sending identify', ['packet' => $payload->__debugInfo()]); $this->send($payload); $this->vc->sentLoginFrame = true; @@ -309,7 +302,7 @@ protected function monitorProcessExit(Process $process, $ss): void protected function handleDavePrepareTransition($data) { - $this->bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); + logger()->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition $this->send(VoicePayload::new( Op::VOICE_DAVE_TRANSITION_READY, @@ -321,20 +314,20 @@ protected function handleDavePrepareTransition($data) protected function handleDaveExecuteTransition($data) { - $this->bot->logger->debug('DAVE Execute Transition', ['data' => $data]); + logger()->debug('DAVE Execute Transition', ['data' => $data]); // Execute the transition // Update local state to reflect the new protocol context } protected function handleDaveTransitionReady($data) { - $this->bot->logger->debug('DAVE Transition Ready', ['data' => $data]); + logger()->debug('DAVE Transition Ready', ['data' => $data]); // Handle transition ready state } protected function handleDavePrepareEpoch($data) { - $this->bot->logger->debug('DAVE Prepare Epoch', ['data' => $data]); + logger()->debug('DAVE Prepare Epoch', ['data' => $data]); // Prepare local MLS group with parameters appropriate for the DAVE protocol version $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, @@ -347,19 +340,19 @@ protected function handleDavePrepareEpoch($data) protected function handleDaveMlsExternalSender($data) { - $this->bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); + logger()->debug('DAVE MLS External Sender', ['data' => $data]); // Handle external sender public key and credential } protected function handleDaveMlsKeyPackage($data) { - $this->bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); + logger()->debug('DAVE MLS Key Package', ['data' => $data]); // Handle MLS key package } protected function handleDaveMlsProposals($data) { - $this->bot->logger->debug('DAVE MLS Proposals', ['data' => $data]); + logger()->debug('DAVE MLS Proposals', ['data' => $data]); // Handle MLS proposals $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_COMMIT_WELCOME, @@ -372,25 +365,25 @@ protected function handleDaveMlsProposals($data) protected function handleDaveMlsCommitWelcome($data) { - $this->bot->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); + logger()->debug('DAVE MLS Commit Welcome', ['data' => $data]); // Handle MLS commit and welcome messages } protected function handleDaveMlsAnnounceCommitTransition($data) { // Handle MLS announce commit transition - $this->bot->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); + logger()->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); } protected function handleDaveMlsWelcome($data) { // Handle MLS welcome message - $this->bot->logger->debug('DAVE MLS Welcome', ['data' => $data]); + logger()->debug('DAVE MLS Welcome', ['data' => $data]); } protected function handleDaveMlsInvalidCommitWelcome($data) { - $this->bot->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); + logger()->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); // Handle invalid commit or welcome message // Reset local group state and generate a new key package $this->send(VoicePayload::new( @@ -401,7 +394,7 @@ protected function handleDaveMlsInvalidCommitWelcome($data) )); } - protected function decodeUDP($message, string &$ip, string &$port): void + protected function decodeUDP($message, string &$ip, int &$port): void { /** * Unpacks the message into an array. @@ -422,7 +415,7 @@ protected function decodeUDP($message, string &$ip, string &$port): void $ip = $unpackedMessageArray['Address']; $port = $unpackedMessageArray['Port']; - $this->bot->logger->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); + logger()->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); $this->send([ 'op' => Op::VOICE_SELECT_PROTO, @@ -446,13 +439,13 @@ public function sendHeartbeat(): void 'seq_ack' => 10, ] )); - $this->bot->logger->debug('sending heartbeat'); + logger()->debug('sending heartbeat'); $this->vc->emit('ws-heartbeat', []); } public function handleClose(int $op, string $reason): void { - $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); + logger()->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); $this->vc->emit('ws-close', [$op, $reason, $this]); $this->clientsConnected = []; @@ -471,13 +464,13 @@ public function handleClose(int $op, string $reason): void // Close UDP socket. if (isset($this->client)) { - $this->bot->logger->warning('closing UDP client'); + logger()->warning('closing UDP client'); $this->client->close(); } // Don't reconnect on a critical opcode or if closed by user. if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this?->userClose) { - $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); + logger()->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); $this->vc->emit('close'); return; @@ -489,7 +482,7 @@ public function handleClose(int $op, string $reason): void return; } - $this->bot->logger->warning('reconnecting in 2 seconds'); + logger()->warning('reconnecting in 2 seconds'); // Retry connect after 2 seconds $this->bot->loop->addTimer(2, function (): void { diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 806eca7b0..386e73d3c 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -20,13 +20,12 @@ use Discord\Exceptions\Voice\AudioAlreadyPlayingException; use Discord\Exceptions\Voice\ClientNotReadyException; use Discord\Helpers\Buffer as RealBuffer; -use Discord\Helpers\ByteBuffer\Buffer; use Discord\Helpers\Collection; use Discord\Helpers\ExCollectionInterface; use Discord\Parts\Channel\Channel; use Discord\Parts\EventData\VoiceSpeaking; -use Discord\Parts\Voice\UserConnected; use Discord\Voice\Client\Packet; +use Discord\Voice\Client\UDP; use Discord\Voice\Client\User; use Discord\Voice\Client\WS; use Discord\Voice\Processes\Dca; @@ -36,14 +35,10 @@ use Discord\WebSockets\Payload; use Discord\WebSockets\VoicePayload; use Evenement\EventEmitter; -use Ratchet\Client\Connector as WsFactory; use Ratchet\Client\WebSocket; -use Ratchet\RFC6455\Messaging\Message; use React\ChildProcess\Process; -use React\Datagram\Factory as DatagramFactory; use React\Datagram\Socket; use React\Dns\Config\Config; -use React\Dns\Resolver\Factory as DNSFactory; use React\EventLoop\TimerInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; @@ -99,12 +94,7 @@ class VoiceClient extends EventEmitter */ public ?WebSocket $voiceWebsocket; - /** - * The UDP client. - * - * @var Socket|null The voiceUDP client. - */ - public $client; + public null|Socket|UDP $client; /** * The Voice WebSocket endpoint. @@ -169,27 +159,7 @@ class VoiceClient extends EventEmitter */ public $timestamp = 0; - /** - * The Voice WebSocket mode. - * - * @link https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes - * @var string The voice mode. - */ - public $mode = 'aead_aes256_gcm_rtpsize'; - /** - * The secret key used for encrypting voice. - * - * @var string|null The secret key. - */ - public $secretKey; - - /** - * The raw secret key. - * - * @var array|null The raw secret key. - */ - public $rawKey; /** * Are we currently set as speaking? @@ -406,63 +376,6 @@ public function start(): bool return true; } - - /** - * Handles a WebSocket close. - * - * @param int $op - * @param string $reason - */ - public function handleWebSocketClose(int $op, string $reason): void - { - $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); - $this->emit('ws-close', [$op, $reason, $this]); - - $this->clientsConnected = []; - $this->voiceWebsocket->close(); - - // Cancel heartbeat timers - if (null !== $this->heartbeat) { - $this->bot->loop->cancelTimer($this->heartbeat); - $this->heartbeat = null; - } - - if (null !== $this->udpHeartbeat) { - $this->bot->loop->cancelTimer($this->udpHeartbeat); - $this->udpHeartbeat = null; - } - - // Close UDP socket. - if (isset($this->client)) { - $this->bot->logger->warning('closing UDP client'); - $this->client->close(); - } - - // Don't reconnect on a critical opcode or if closed by user. - if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this->userClose) { - $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); - $this->emit('close'); - - return; - } - - if (in_array($op, [Op::CLOSE_VOICE_DISCONNECTED])) { - $this->emit('close'); - - return; - } - - $this->bot->logger->warning('reconnecting in 2 seconds'); - - // Retry connect after 2 seconds - $this->bot->loop->addTimer(2, function (): void { - $this->reconnecting = true; - $this->sentLoginFrame = false; - - $this->start(); - }); - } - /** * Handles a voice server change. * diff --git a/src/Discord/functions.php b/src/Discord/functions.php index 8ae6855e7..d62ed0a63 100644 --- a/src/Discord/functions.php +++ b/src/Discord/functions.php @@ -20,6 +20,7 @@ use Discord\Parts\Part; use Discord\Parts\User\Member; use Discord\Parts\User\User; +use Psr\Log\LoggerInterface; use React\EventLoop\Loop; use React\EventLoop\LoopInterface; use React\Promise\Deferred; @@ -335,6 +336,21 @@ function nowait(PromiseInterface $promiseInterface) return $resolved; } +function discord(): Discord +{ + return Discord::getInstance(); +} + +function logger(): LoggerInterface +{ + return Discord::getInstance()->getLogger(); +} + +function loop(): LoopInterface +{ + return Discord::getInstance()->getLoop(); +} + /** * File namespaces that were changed in new versions are aliased. */ From d7bb956d7a3be0b6d6395fc979567bebd54276f3 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 00:06:55 +0100 Subject: [PATCH 101/121] Reverts visibility to protected --- src/Discord/Discord.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 97fd7c7e7..ab8e7a219 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -129,7 +129,7 @@ class Discord * * @var LoopInterface Event loop. */ - public $loop; + protected $loop; /** * The WebSocket client factory. From 00da654f7f88ef88f511f8c3a05eb38d19bbeb52 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Fri, 4 Jul 2025 04:58:18 -0400 Subject: [PATCH 102/121] Reorder imports --- src/Discord/Discord.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index ab8e7a219..d98d7d0f1 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -45,8 +45,6 @@ use Discord\WebSockets\Op; use Discord\WebSockets\Payload; use Evenement\EventEmitterTrait; -use function React\Async\coroutine; -use function React\Promise\all; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; use Monolog\Logger as Monolog; @@ -58,11 +56,13 @@ use React\EventLoop\LoopInterface; use React\EventLoop\TimerInterface; use React\Promise\Deferred; - use React\Promise\PromiseInterface; use React\Socket\Connector as SocketConnector; use Symfony\Component\OptionsResolver\OptionsResolver; +use function React\Async\coroutine; +use function React\Promise\all; + /** * The Discord client class. * From d7e1bbd2f18bf728f254c8a81ea61571e8b600a6 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 11:51:29 +0100 Subject: [PATCH 103/121] Reverts back usage of logger --- src/Discord/Voice/Client/WS.php | 112 +++++++++++--------------------- 1 file changed, 38 insertions(+), 74 deletions(-) diff --git a/src/Discord/Voice/Client/WS.php b/src/Discord/Voice/Client/WS.php index 6c4413351..950a90bb9 100644 --- a/src/Discord/Voice/Client/WS.php +++ b/src/Discord/Voice/Client/WS.php @@ -72,7 +72,7 @@ public function __construct( $f("wss://" . $this->data['endpoint'] . "?v=" . self::$version) ->then( fn (WebSocket $ws) => $this->handleConnection($ws), - fn (\Throwable $e) => logger()->error( + fn (\Throwable $e) => $this->bot->logger->error( 'Failed to connect to voice gateway: {error}', ['error' => $e->getMessage()] ) && $this->vc->emit('error', [$e]) @@ -94,7 +94,7 @@ public static function make( */ public function handleConnection(WebSocket $ws): void { - logger()->debug('connected to voice websocket'); + $this->bot->logger->debug('connected to voice websocket'); $resolver = (new DnsFactory())->createCached($this->data['dnsConfig'], $this->bot->loop); $udpfac = new SocketFactory($this->bot->loop, $resolver, $this); @@ -113,7 +113,7 @@ public function handleConnection(WebSocket $ws): void $start = $data->d->t; $diff = ($end - $start) * 1000; - logger()->debug('received heartbeat ack', ['response_time' => $diff]); + $this->bot->logger->debug('received heartbeat ack', ['response_time' => $diff]); $this->vc->emit('ws-ping', [$diff]); $this->vc->emit('ws-heartbeat-ack', [$data->d->t]); break; @@ -124,7 +124,7 @@ public function handleConnection(WebSocket $ws): void $this->rawKey = $data->d->secret_key; $this->secretKey = implode('', array_map(static fn ($value) => pack('C', $value), $this->rawKey)); - logger()->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received description packet, vc ready', ['data' => json_decode(json_encode($data->d), true)]); if (! $this->vc->reconnecting) { $this->vc->emit('ready', [$this->vc]); @@ -139,7 +139,7 @@ public function handleConnection(WebSocket $ws): void break; case Op::VOICE_SPEAKING: // currently connected users - logger()->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received speaking packet', ['data' => json_decode(json_encode($data->d), true)]); $this->vc->emit('speaking', [$data->d->speaking, $data->d->user_id, $this->vc]); $this->vc->emit("speaking.{$data->d->user_id}", [$data->d->speaking, $this->vc]); $this->vc->speakingStatus[$data->d->user_id] = $this->bot->getFactory()->create(VoiceSpeaking::class, $data->d); @@ -150,21 +150,21 @@ public function handleConnection(WebSocket $ws): void $this->vc->heartbeat = $this->bot->loop->addPeriodicTimer($this->vc->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); break; case Op::VOICE_CLIENTS_CONNECT: - logger()->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); # "d" contains an array with ['user_ids' => array] $this->vc->users = array_map(fn (int $userId) => $this->bot->getFactory()->create(UserConnected::class, $userId), $data->d->user_ids); break; case Op::VOICE_CLIENT_DISCONNECT: - logger()->debug('received client disconnected packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received client disconnected packet', ['data' => json_decode(json_encode($data->d), true)]); unset($this->vc->clientsConnected[$data->d->user_id]); break; case Op::VOICE_CLIENT_UNKNOWN_15: case Op::VOICE_CLIENT_UNKNOWN_18: - logger()->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); + $this->bot->logger->debug('received unknown opcode', ['data' => json_decode(json_encode($data), true)]); break; case Op::VOICE_CLIENT_PLATFORM: - logger()->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received platform packet', ['data' => json_decode(json_encode($data->d), true)]); # handlePlatformPerUser # platform = 0 assumed to be Desktop break; @@ -206,7 +206,7 @@ public function handleConnection(WebSocket $ws): void $this->vc->udpPort = $data->d->port; $this->vc->ssrc = $data->d->ssrc; - logger()->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); + $this->bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); /** @var PromiseInterface */ $udpfac->createClient("{$data->d->ip}:" . $this->vc->udpPort)->then(function (UDP $client): void { @@ -216,19 +216,19 @@ public function handleConnection(WebSocket $ws): void ->handleErrors() ->decodeOnce(); }, function (\Throwable $e): void { - logger()->error('error while connecting to udp', ['e' => $e->getMessage()]); + $this->bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->vc->emit('error', [$e]); }); break; } default: - logger()->warning('Unknown opcode.', $data); + $this->bot->logger->warning('Unknown opcode.', $data); break; } }); $ws->on('error', function ($e): void { - logger()->error('error with voice websocket', ['e' => $e->getMessage()]); + $this->bot->logger->error('error with voice websocket', ['e' => $e->getMessage()]); $this->vc->emit('ws-error', [$e]); }); @@ -249,7 +249,7 @@ public function handleConnection(WebSocket $ws): void ], ); - logger()->debug('sending identify', ['packet' => $payload->__debugInfo()]); + $this->bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); $this->send($payload); $this->vc->sentLoginFrame = true; @@ -300,9 +300,9 @@ protected function monitorProcessExit(Process $process, $ss): void }); } - protected function handleDavePrepareTransition($data) + protected function handleDavePrepareTransition($data): void { - logger()->debug('DAVE Prepare Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition $this->send(VoicePayload::new( Op::VOICE_DAVE_TRANSITION_READY, @@ -312,22 +312,22 @@ protected function handleDavePrepareTransition($data) )); } - protected function handleDaveExecuteTransition($data) + protected function handleDaveExecuteTransition($data): void { - logger()->debug('DAVE Execute Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE Execute Transition', ['data' => $data]); // Execute the transition // Update local state to reflect the new protocol context } - protected function handleDaveTransitionReady($data) + protected function handleDaveTransitionReady($data): void { - logger()->debug('DAVE Transition Ready', ['data' => $data]); + $this->bot->logger->debug('DAVE Transition Ready', ['data' => $data]); // Handle transition ready state } - protected function handleDavePrepareEpoch($data) + protected function handleDavePrepareEpoch($data): void { - logger()->debug('DAVE Prepare Epoch', ['data' => $data]); + $this->bot->logger->debug('DAVE Prepare Epoch', ['data' => $data]); // Prepare local MLS group with parameters appropriate for the DAVE protocol version $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_KEY_PACKAGE, @@ -338,21 +338,21 @@ protected function handleDavePrepareEpoch($data) )); } - protected function handleDaveMlsExternalSender($data) + protected function handleDaveMlsExternalSender($data): void { - logger()->debug('DAVE MLS External Sender', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); // Handle external sender public key and credential } - protected function handleDaveMlsKeyPackage($data) + protected function handleDaveMlsKeyPackage($data): void { - logger()->debug('DAVE MLS Key Package', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); // Handle MLS key package } - protected function handleDaveMlsProposals($data) + protected function handleDaveMlsProposals($data): void { - logger()->debug('DAVE MLS Proposals', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Proposals', ['data' => $data]); // Handle MLS proposals $this->send(VoicePayload::new( Op::VOICE_DAVE_MLS_COMMIT_WELCOME, @@ -363,27 +363,27 @@ protected function handleDaveMlsProposals($data) )); } - protected function handleDaveMlsCommitWelcome($data) + protected function handleDaveMlsCommitWelcome($data): void { - logger()->debug('DAVE MLS Commit Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); // Handle MLS commit and welcome messages } protected function handleDaveMlsAnnounceCommitTransition($data) { // Handle MLS announce commit transition - logger()->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); } protected function handleDaveMlsWelcome($data) { // Handle MLS welcome message - logger()->debug('DAVE MLS Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Welcome', ['data' => $data]); } protected function handleDaveMlsInvalidCommitWelcome($data) { - logger()->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); + $this->bot->logger->debug('DAVE MLS Invalid Commit Welcome', ['data' => $data]); // Handle invalid commit or welcome message // Reset local group state and generate a new key package $this->send(VoicePayload::new( @@ -394,42 +394,6 @@ protected function handleDaveMlsInvalidCommitWelcome($data) )); } - protected function decodeUDP($message, string &$ip, int &$port): void - { - /** - * Unpacks the message into an array. - * - * C2 (unsigned char) | Type | 2 bytes | Values 0x1 and 0x2 indicate request and response, respectively - * n (unsigned short) | Length | 2 bytes | Length of the following data - * I (unsigned int) | SSRC | 4 bytes | The SSRC of the sender - * A64 (string) | Address | 64 bytes | The IP address of the sender - * n (unsigned short) | Port | 2 bytes | The port of the sender - * - * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery - * @see https://www.php.net/manual/en/function.unpack.php - * @see https://www.php.net/manual/en/function.pack.php For the formats - */ - $unpackedMessageArray = \unpack("C2Type/nLength/ISSRC/A64Address/nPort", $message); - - $this->ssrc = $unpackedMessageArray['SSRC']; - $ip = $unpackedMessageArray['Address']; - $port = $unpackedMessageArray['Port']; - - logger()->debug('received our IP and port', ['ip' => $ip, 'port' => $port]); - - $this->send([ - 'op' => Op::VOICE_SELECT_PROTO, - 'd' => [ - 'protocol' => 'udp', - 'data' => [ - 'address' => $ip, - 'port' => $port, - 'mode' => $this->mode, - ], - ], - ]); - } - public function sendHeartbeat(): void { $this->send(VoicePayload::new( @@ -439,13 +403,13 @@ public function sendHeartbeat(): void 'seq_ack' => 10, ] )); - logger()->debug('sending heartbeat'); + $this->bot->logger->debug('sending heartbeat'); $this->vc->emit('ws-heartbeat', []); } public function handleClose(int $op, string $reason): void { - logger()->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); + $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); $this->vc->emit('ws-close', [$op, $reason, $this]); $this->clientsConnected = []; @@ -464,13 +428,13 @@ public function handleClose(int $op, string $reason): void // Close UDP socket. if (isset($this->client)) { - logger()->warning('closing UDP client'); + $this->bot->logger->warning('closing UDP client'); $this->client->close(); } // Don't reconnect on a critical opcode or if closed by user. if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this?->userClose) { - logger()->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); + $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); $this->vc->emit('close'); return; @@ -482,7 +446,7 @@ public function handleClose(int $op, string $reason): void return; } - logger()->warning('reconnecting in 2 seconds'); + $this->bot->logger->warning('reconnecting in 2 seconds'); // Retry connect after 2 seconds $this->bot->loop->addTimer(2, function (): void { From ead85a30f868d3c451b1890796e06c34e5ad93fa Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 15:25:25 +0100 Subject: [PATCH 104/121] Updates usage of get voice client Adds some todos --- src/Discord/Discord.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index d98d7d0f1..900a9707d 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -1278,13 +1278,13 @@ public function updatePresence(?Activity $activity = null, bool $idle = false, s * Gets a voice client from a guild ID. Returns null if there is no voice client. * * @param string $guild_id The guild ID to look up. - * @deprecated Use $discord->voice->getVoiceClient() + * @deprecated Use $discord->voice->getClient($guildId) * * @return VoiceClient|null */ - public function getVoiceClient(string $guild_id): ?VoiceClient + public function getVoiceClient(string|int $guildId): ?VoiceClient { - return $this->voice->clients[$guild_id] ?? null; + return $this->voice->getClient($guildId); } /** @@ -1626,6 +1626,7 @@ public function __get(string $name) } if (null === $this->client) { + // TODO: Throw an exception here? return; } @@ -1641,6 +1642,7 @@ public function __get(string $name) public function __set(string $name, $value): void { if (null === $this->client) { + // TODO: Throw an exception here? return; } @@ -1666,6 +1668,8 @@ public function getChannel($channel_id): ?Channel return $channel; } + // TODO: Throw an exception here? + return null; } @@ -1715,6 +1719,7 @@ public function listenCommand($name, ?callable $callback = null, ?callable $auto public function __call(string $name, array $params) { if (null === $this->client) { + // TODO: Throw an exception here? return; } From fc3d73d6b3f970f9780c5197dccd2f14dcd71e42 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 15:26:19 +0100 Subject: [PATCH 105/121] Updates function to remove duplication of "return $deferred->promise();" --- src/Discord/Voice/VoiceManager.php | 38 +++++++++++++----------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index 2a11fadb2..2ac392ef1 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -11,9 +11,6 @@ use Discord\WebSockets\Op; use Discord\WebSockets\VoicePayload; use Evenement\EventEmitterTrait; -use Psr\Log\LoggerInterface; -use Ratchet\Client\WebSocket; -use React\EventLoop\LoopInterface; use React\Promise\Deferred; final class VoiceManager @@ -48,27 +45,24 @@ public function createClientAndJoinChannel( { $deferred = new Deferred(); - if (! $channel->isVoiceBased()) { - $deferred->reject(new \RuntimeException('Channel must allow voice.')); + try { + if (! $channel->isVoiceBased()) { + throw new \RuntimeException('Channel must allow voice.'); + } - return $deferred->promise(); - } - - if (! $channel->canJoin()) { - $deferred->reject(new \RuntimeException('The bot must have proper permissions to join this channel.')); - - return $deferred->promise(); - } - - if (! $channel->canSpeak() && ! $mute) { - $deferred->reject(new \RuntimeException('The bot must have permission to speak in this channel.')); - - return $deferred->promise(); - } + if (! $channel->canJoin()) { + throw new \RuntimeException('The bot must have proper permissions to join this channel.'); + } - if (isset($this->clients[$channel->guild_id])) { - $deferred->reject(new \RuntimeException('You cannot join more than one voice channel per guild.')); + if (! $channel->canSpeak() && ! $mute) { + throw new \RuntimeException('The bot must have permission to speak in this channel.'); + } + if (isset($this->clients[$channel->guild_id])) { + throw new \RuntimeException('You cannot join more than one voice channel per guild.'); + } + } catch (\Throwable $th) { + $deferred->reject($th); return $deferred->promise(); } @@ -96,7 +90,7 @@ public function createClientAndJoinChannel( return $deferred->promise(); } - public function getClient(string $guildId): ?VoiceClient + public function getClient(string|int $guildId): ?VoiceClient { if (! isset($this->clients[$guildId])) { return null; From 23d3d3dbeeb87679482468d94e919f0ac5e89dc0 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 15:31:53 +0100 Subject: [PATCH 106/121] Remove usage of the DnsFactory from within the VoiceClient into the custom SocketFactory class Moves some function from VoiceClient into UDP client and WS client --- src/Discord/Factory/SocketFactory.php | 11 +- src/Discord/Voice/Client/UDP.php | 138 +++++++++++++++++++-- src/Discord/Voice/Client/WS.php | 163 ++++++++++--------------- src/Discord/Voice/VoiceClient.php | 167 ++++++-------------------- 4 files changed, 234 insertions(+), 245 deletions(-) diff --git a/src/Discord/Factory/SocketFactory.php b/src/Discord/Factory/SocketFactory.php index 7edc74e64..7e8c154f0 100644 --- a/src/Discord/Factory/SocketFactory.php +++ b/src/Discord/Factory/SocketFactory.php @@ -4,13 +4,10 @@ namespace Discord\Factory; -use Discord\Discord; use Discord\Voice\Client\UDP; use Discord\Voice\Client\WS; use React\Datagram\Factory; -use React\Datagram\Socket; -use function Discord\logger; -use function Discord\loop; +use React\Dns\Resolver\Factory as DnsFactory; final class SocketFactory extends Factory { @@ -18,7 +15,11 @@ final class SocketFactory extends Factory public function __construct($loop = null, $resolver = null, ?WS $ws = null) { - parent::__construct($loop ?? loop(), $resolver); + if (null === $resolver) { + $resolver = (new DnsFactory())->createCached($ws->data['dnsConfig'], $loop); + } + + parent::__construct($loop, $resolver); if ($ws !== null) { $this->ws = $ws; diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index 7adb24a3f..259bceaf7 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -5,26 +5,70 @@ use Discord\Helpers\ByteBuffer\Buffer; use Discord\Voice\VoiceClient; use Discord\WebSockets\Op; +use React\EventLoop\TimerInterface; use function Discord\logger; use function Discord\loop; use React\Datagram\Socket; final class UDP extends Socket { + /** + * The Parent Voice WebSocket Client. + * + * @var WS + */ protected WS $ws; + /** + * Silence Frame Remain Count. + * + * @var int Amount of silence frames remaining. + */ + public int $silenceRemaining = 5; + + /** + * The Opus Silence Frame. + * + * @var string The silence frame. + */ + public const string SILENCE_FRAME = "\0xF8\0xFF\0xFE"; + + /** + * The stream time of the last packet. + * + * @var int The time we sent the last packet. + */ + public int $streamTime = 0; + + protected ?TimerInterface $heartbeat; + + protected $hbInterval; + + protected $hbSequence = 0; + + public string $ip; + + public int $port; + + public int $ssrc; + public function __construct($loop, $socket, $buffer = null, ?WS $ws = null) { parent::__construct($loop, $socket, $buffer); if ($ws !== null) { $this->ws = $ws; + + if (null === $this->hbInterval) { + // Set the heartbeat interval to the default value if not set. + $this->hbInterval = $this->ws->vc->heartbeatInterval; + } } } - public function handleMessages(VoiceClient $vc, $secret): self + public function handleMessages(string $secret): self { - return $this->on('message', static fn (string $message) => $vc->handleAudioData( + return $this->on('message', fn (string $message) => $this->ws->vc->handleAudioData( new Packet($message, key: $secret) )); } @@ -35,28 +79,40 @@ public function handleSsrcSending(): self $buffer[1] = "\x01"; $buffer[3] = "\x46"; $buffer->writeUInt32BE($this->ws->vc->ssrc, 4); - loop()->addTimer(0.1, fn () => $this->ws->vc->client->send($buffer->__toString())); + loop()->addTimer(0.1, fn () => $this->send($buffer->__toString())); return $this; } public function handleHeartbeat(): self { - $this->ws->vc->udpHeartbeat = loop()->addPeriodicTimer($this->ws->vc->heartbeatInterval / 1000, function (): void { - $buffer = new Buffer(9); - $buffer[0] = 0xC9; - $buffer->writeUInt64LE($this->ws->vc->heartbeatSeq, 1); - ++$this->ws->vc->heartbeatSeq; + if (null === $this->hbInterval) { + $this->hbInterval = $this->ws->vc->heartbeatInterval; + } - $this->ws->vc->client->send($buffer->__toString()); - $this->ws->vc->emit('udp-heartbeat', []); + $this->heartbeat = loop()->addPeriodicTimer( + $this->hbInterval / 1000, + function (): void { + $buffer = new Buffer(9); + $buffer[0] = 0xC9; + $buffer->writeUInt64LE($this->hbSequence, 1); + ++$this->hbSequence; - logger()->debug('sent UDP heartbeat'); - }); + $this->send($buffer->__toString()); + $this->ws->vc->emit('udp-heartbeat', []); + + logger()->debug('sent UDP heartbeat'); + } + ); return $this; } + /** + * Decodes the UDP message once. + * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery + * @return UDP + */ public function decodeOnce(): self { return $this->once('message', function (string $message) { @@ -102,4 +158,62 @@ public function handleErrors(): self $this->ws->vc->emit('udp-error', [$e]); }); } + + /** + * Insert 5 frames of silence. + * + * @link https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation + */ + public function insertSilence(): void + { + while (--$this->silenceRemaining > 0) { + $this->sendBuffer(self::SILENCE_FRAME); + } + } + + /** + * Sends a buffer to the UDP socket. + * + * @param string $data The data to send to the UDP server. + */ + public function sendBuffer(string $data): void + { + if (! $this->ws->vc->ready) { + return; + } + + $packet = new Packet( + $data, + $this->ws->vc->ssrc, + $this->ws->vc->seq, + $this->ws->vc->timestamp, + true, + $this->ws->secretKey, + ); + $this->send($packet->__toString()); + + $this->streamTime = (int) microtime(true); + + $this->ws->vc->emit('packet-sent', [$packet]); + } + + public function close(): void + { + if ($this->heartbeat) { + loop()->cancelTimer($this->heartbeat); + $this->heartbeat = null; + } + + parent::close(); + } + + public function refreshSilenceFrames(): void + { + if (!$this->ws->vc->paused) { + // If the voice client is paused, we don't need to refresh the silence frames. + return; + } + + $this->silenceRemaining = 5; + } } diff --git a/src/Discord/Voice/Client/WS.php b/src/Discord/Voice/Client/WS.php index 950a90bb9..fa0d0d9f2 100644 --- a/src/Discord/Voice/Client/WS.php +++ b/src/Discord/Voice/Client/WS.php @@ -6,7 +6,6 @@ use Discord\Discord; use Discord\Factory\SocketFactory; -use Discord\Helpers\ByteBuffer\Buffer; use Discord\Parts\EventData\VoiceSpeaking; use Discord\Parts\Voice\UserConnected; use Discord\Voice\VoiceClient; @@ -15,12 +14,8 @@ use Ratchet\Client\Connector; use Ratchet\Client\WebSocket; use Ratchet\RFC6455\Messaging\Message; -use React\ChildProcess\Process; -use React\Datagram\Factory; -use React\Datagram\Socket; -use React\Dns\Resolver\Factory as DnsFactory; +use React\EventLoop\TimerInterface; use React\Promise\PromiseInterface; -use function Discord\logger; final class WS { @@ -58,10 +53,18 @@ final class WS public $ssrc; + private bool $sentLoginFrame = false; + + protected TimerInterface $heartbeat; + + protected $hbInterval; + + protected int $hbSequence = 0; + public function __construct( public VoiceClient $vc, protected ?Discord $bot = null, - protected ?array $data = [], + public ?array $data = [], ) { $this->data ??= $this->vc->data; $this->bot ??= $this->vc->bot; @@ -75,15 +78,12 @@ public function __construct( fn (\Throwable $e) => $this->bot->logger->error( 'Failed to connect to voice gateway: {error}', ['error' => $e->getMessage()] - ) && $this->vc->emit('error', [$e]) + ) && $this->vc->emit('error', arguments: [$e]) ); } - public static function make( - VoiceClient $vc, - ?Discord $bot = null, - ?array $data = null - ): self { + public static function make(VoiceClient $vc, ?Discord $bot = null, ?array $data = null): self + { return new self($vc, $bot, $data); } @@ -96,12 +96,9 @@ public function handleConnection(WebSocket $ws): void { $this->bot->logger->debug('connected to voice websocket'); - $resolver = (new DnsFactory())->createCached($this->data['dnsConfig'], $this->bot->loop); - $udpfac = new SocketFactory($this->bot->loop, $resolver, $this); + $udpfac = new SocketFactory($this->bot->loop, ws: $this); - $this->socket = $this->vc->voiceWebsocket = $ws; - - $ip = $port = ''; + $this->socket = $this->vc->ws = $ws; $ws->on('message', function (Message $message) use ($udpfac): void { $data = json_decode($message->getPayload()); @@ -131,10 +128,12 @@ public function handleConnection(WebSocket $ws): void } else { $this->vc->reconnecting = false; $this->vc->emit('resumed', [$this->vc]); + # TODO: check if this can fix the reconnect issue + $this->vc->emit('ready', [$this->vc]); } if (! $this->vc->deaf && $this->secretKey) { - $this->vc->client->handleMessages($this->vc, $this->secretKey); + $this->vc->udp->handleMessages($this->secretKey); } break; @@ -145,9 +144,12 @@ public function handleConnection(WebSocket $ws): void $this->vc->speakingStatus[$data->d->user_id] = $this->bot->getFactory()->create(VoiceSpeaking::class, $data->d); break; case Op::VOICE_HELLO: - $this->vc->heartbeatInterval = $data->d->heartbeat_interval; + $this->hbInterval = $data->d->heartbeat_interval; $this->sendHeartbeat(); - $this->vc->heartbeat = $this->bot->loop->addPeriodicTimer($this->vc->heartbeatInterval / 1000, fn () => $this->sendHeartbeat()); + $this->heartbeat = $this->bot->loop->addPeriodicTimer( + $this->hbInterval / 1000, + fn () => $this->sendHeartbeat() + ); break; case Op::VOICE_CLIENTS_CONNECT: $this->bot->logger->debug('received clients connected packet', ['data' => json_decode(json_encode($data->d), true)]); @@ -203,18 +205,21 @@ public function handleConnection(WebSocket $ws): void break; case Op::VOICE_READY: { - $this->vc->udpPort = $data->d->port; $this->vc->ssrc = $data->d->ssrc; $this->bot->logger->debug('received voice ready packet', ['data' => json_decode(json_encode($data->d), true)]); /** @var PromiseInterface */ - $udpfac->createClient("{$data->d->ip}:" . $this->vc->udpPort)->then(function (UDP $client): void { - $this->vc->client = $client; + $udpfac->createClient("{$data->d->ip}:" . $data->d->port)->then(function (UDP $client) use ($data): void { + $this->vc->udp = $client; $client->handleSsrcSending() ->handleHeartbeat() ->handleErrors() ->decodeOnce(); + + $client->ip = $data->d->ip; + $client->port = $data->d->port; + $client->ssrc = $data->d->ssrc; }, function (\Throwable $e): void { $this->bot->logger->error('error while connecting to udp', ['e' => $e->getMessage()]); $this->vc->emit('error', [$e]); @@ -234,25 +239,7 @@ public function handleConnection(WebSocket $ws): void $ws->on('close', [$this, 'handleClose']); - - if ($this->vc->sentLoginFrame) { - return; - } - - $payload = VoicePayload::new( - Op::VOICE_IDENTIFY, - [ - 'server_id' => $this->vc->channel->guild_id, - 'user_id' => $this->data['user_id'], - 'session_id' => $this->data['session'], - 'token' => $this->data['token'], - ], - ); - - $this->bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); - - $this->send($payload); - $this->vc->sentLoginFrame = true; + $this->handleSendingOfLoginFrame(); } /** @@ -266,49 +253,13 @@ public function send(VoicePayload|array $data): void $this->socket->send($json); } - /** - * Monitor a process for exit and trigger callbacks when it exits - * - * @param Process $process The process to monitor - * @param object $ss The speaking status object - * @param callable $createDecoder Function to create a new decoder if needed - */ - protected function monitorProcessExit(Process $process, $ss): void - { - // Store the process ID - // $pid = $process->getPid(); - - // Check every second if the process is still running - $this->monitorProcessTimer = $this->bot->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { - // Check if the process is still running - if (!$process->isRunning()) { - // Get the exit code - $exitCode = $process->getExitCode(); - - // Clean up the timer - $this->bot->loop->cancelTimer($this->monitorProcessTimer); - - // If exit code indicates an error, emit event and recreate decoder - if ($exitCode > 0) { - $this->vc->emit('decoder-error', [$exitCode, null, $ss]); - //$this->createDecoder($ss); - } - - // Clean up temporary files - // $this->cleanupTempFiles(); - } - }); - } - protected function handleDavePrepareTransition($data): void { $this->bot->logger->debug('DAVE Prepare Transition', ['data' => $data]); // Prepare local state necessary to perform the transition $this->send(VoicePayload::new( Op::VOICE_DAVE_TRANSITION_READY, - [ - 'transition_id' => $data->d->transition_id, - ], + ['transition_id' => $data->d->transition_id], )); } @@ -400,7 +351,7 @@ public function sendHeartbeat(): void Op::VOICE_HEARTBEAT, [ 't' => (int) microtime(true), - 'seq_ack' => 10, + 'seq_ack' => ++$this->hbSequence, ] )); $this->bot->logger->debug('sending heartbeat'); @@ -412,48 +363,62 @@ public function handleClose(int $op, string $reason): void $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); $this->vc->emit('ws-close', [$op, $reason, $this]); - $this->clientsConnected = []; - $this->socket->close(); + $this->vc->clientsConnected = []; // Cancel heartbeat timers if (null !== $this->vc->heartbeat) { $this->bot->loop->cancelTimer($this->vc->heartbeat); - $this->heartbeat = null; - } - - if (null !== $this->vc->udpHeartbeat) { - $this->bot->loop->cancelTimer($this->vc->udpHeartbeat); - $this->vc->udpHeartbeat = null; + $this->vc->heartbeat = null; } // Close UDP socket. - if (isset($this->client)) { + if (isset($this->vc->udp)) { $this->bot->logger->warning('closing UDP client'); - $this->client->close(); + $this->vc->udp->close(); } + $this->socket->close(); + // Don't reconnect on a critical opcode or if closed by user. - if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this?->userClose) { + if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this?->vc->userClose) { $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); $this->vc->emit('close'); return; } - if (in_array($op, [Op::CLOSE_VOICE_DISCONNECTED])) { - $this->vc->emit('close'); - - return; - } - $this->bot->logger->warning('reconnecting in 2 seconds'); // Retry connect after 2 seconds $this->bot->loop->addTimer(2, function (): void { - $this->reconnecting = true; + $this->vc->reconnecting = true; + $this->vc->sentLoginFrame = false; $this->sentLoginFrame = false; $this->vc->start(); }); } + + public function handleSendingOfLoginFrame(): void + { + if ($this->sentLoginFrame) { + return; + } + + $payload = VoicePayload::new( + Op::VOICE_IDENTIFY, + [ + 'server_id' => $this->vc->channel->guild_id, + 'user_id' => $this->data['user_id'], + 'session_id' => $this->data['session'], + 'token' => $this->data['token'], + ], + ); + + $this->bot->logger->debug('sending identify', ['packet' => $payload->__debugInfo()]); + + $this->send($payload); + $this->sentLoginFrame = true; + $this->vc->sentLoginFrame = true; + } } diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 386e73d3c..65f67aba8 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -59,13 +59,6 @@ class VoiceClient extends EventEmitter */ public const DCA_VERSION = 'DCA1'; - /** - * The Opus Silence Frame. - * - * @var string The silence frame. - */ - public const SILENCE_FRAME = "\0xF8\0xFF\0xFE"; - /** * Is the voice client ready? * @@ -92,23 +85,21 @@ class VoiceClient extends EventEmitter * * @var WebSocket|null The voice WebSocket client. */ - public ?WebSocket $voiceWebsocket; - - public null|Socket|UDP $client; + public ?WebSocket $ws; /** - * The Voice WebSocket endpoint. + * The UDP client instance. * - * @var string|null The endpoint the Voice WebSocket and UDP client will connect to. + * @var null|Socket|\Discord\Voice\Client\UDP */ - public $endpoint; + public null|Socket|UDP $udp; /** - * The port the UDP client will use. + * The Voice WebSocket endpoint. * - * @var int|null The port that the UDP client will connect to. + * @var string|null The endpoint the Voice WebSocket and UDP client will connect to. */ - public $udpPort; + public $endpoint; /** * The UDP heartbeat interval. @@ -124,13 +115,6 @@ class VoiceClient extends EventEmitter */ public $heartbeat; - /** - * The UDP heartbeat timer. - * - * @var TimerInterface|null The heartbeat periodic timer. - */ - public $udpHeartbeat; - /** * The UDP heartbeat sequence. * @@ -159,8 +143,6 @@ class VoiceClient extends EventEmitter */ public $timestamp = 0; - - /** * Are we currently set as speaking? * @@ -189,13 +171,6 @@ class VoiceClient extends EventEmitter */ public $startTime; - /** - * The stream time of the last packet. - * - * @var int The time we sent the last packet. - */ - public $streamTime = 0; - /** * The size of audio frames, in milliseconds. * @@ -286,13 +261,6 @@ class VoiceClient extends EventEmitter */ public $dnsConfig; - /** - * Silence Frame Remain Count. - * - * @var int Amount of silence frames remaining. - */ - public $silenceRemaining = 5; - /** * readopus Timer. * @@ -339,6 +307,8 @@ class VoiceClient extends EventEmitter * @param array $data * @param bool $deaf Default: false * @param bool $mute Default: false + * @param Deferred|null $deferred + * @param VoiceManager|null $manager */ public function __construct( public Discord $bot, @@ -381,18 +351,17 @@ public function start(): bool * * @param array $data New voice server information. */ - public function handleVoiceServerChange(array $data = []): void + public function handleVoiceServerChange(Payload|array $data = []): void { $this->bot->logger->debug('voice server has changed, dynamically changing servers in the background', ['data' => $data]); $this->reconnecting = true; - $this->sentLoginFrame = false; + $this->pause(); - $this->client->close(); - $this->voiceWebsocket->close(); + $this->udp->close(); + $this->ws->close(); $this->bot->loop->cancelTimer($this->heartbeat); - $this->bot->loop->cancelTimer($this->udpHeartbeat); $this->data['token'] = $data['token']; // set the token if it changed $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); @@ -580,7 +549,7 @@ protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) // If the client is paused, delay by frame size and check again. if ($this->paused) { - $this->insertSilence(); + $this->udp->insertSilence(); $this->readOpusTimer = $this->bot->loop->addTimer($this->frameSize / 1000, fn () => $this->readOggOpus($deferred, $ogg, $loops)); return; @@ -601,7 +570,7 @@ protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) $this->seq = 0; } - $this->sendBuffer($packet); + $this->udp->sendBuffer($packet); // increment timestamp // uint32 overflow protection @@ -713,7 +682,7 @@ protected function readDCAOpus(Deferred $deferred): void // If the client is paused, delay by frame size and check again. if ($this->paused) { - $this->insertSilence(); + $this->udp->insertSilence(); $this->readOpusTimer = $this->bot->loop->addTimer($this->frameSize / 1000, fn () => $this->readDCAOpus($deferred)); return; @@ -724,7 +693,7 @@ protected function readDCAOpus(Deferred $deferred): void // Read opus data return $this->buffer->read($opusLength, null, 1000); })->then(function ($opus) use ($deferred) { - $this->sendBuffer($opus); + $this->udp->sendBuffer($opus); // increment sequence // uint16 overflow protection @@ -762,26 +731,6 @@ protected function reset(): void $this->silenceRemaining = 5; } - /** - * Sends a buffer to the UDP socket. - * - * @param string $data The data to send to the UDP server. - * @todo Fix after new change in VoicePacket - */ - protected function sendBuffer(string $data): void - { - if (! $this->ready) { - return; - } - - $packet = new Packet($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secretKey, log: $this->bot->logger); - $this->client->send((string) $packet); - - $this->streamTime = (int) microtime(true); - - $this->emit('packet-sent', [$packet]); - } - /** * Sets the speaking value of the client. * @@ -789,7 +738,7 @@ protected function sendBuffer(string $data): void * * @throws \RuntimeException */ - /* public function setSpeaking(bool $speaking = true): void + public function setSpeaking(bool $speaking = true): void { if ($this->speaking == $speaking) { return; @@ -799,7 +748,7 @@ protected function sendBuffer(string $data): void throw new \RuntimeException('Voice Client is not ready.'); } - $this->send(VoicePayload::new( + $this->ws->send(VoicePayload::new( Op::VOICE_SPEAKING, [ 'speaking' => $speaking, @@ -809,7 +758,7 @@ protected function sendBuffer(string $data): void )); $this->speaking = $speaking; - } */ + } /** * Switches voice channels. @@ -902,8 +851,6 @@ public function setAudioApplication(string $app): void $this->audioApplication = $app; } - - /** * Sends a message to the main websocket. * @@ -942,10 +889,10 @@ public function setMuteDeaf(bool $mute, bool $deaf): void ], )); - $this->client->removeListener('message', [$this, 'handleAudioData']); + $this->udp->removeListener('message', [$this, 'handleAudioData']); if (! $deaf) { - $this->client->on('message', [$this, 'handleAudioData']); + $this->udp->on('message', [$this, 'handleAudioData']); } } @@ -965,7 +912,7 @@ public function pause(): void } $this->paused = true; - $this->silenceRemaining = 5; + $this->udp->refreshSilenceFrames(); } /** @@ -999,7 +946,7 @@ public function stop(): void } $this->buffer->end(); - $this->insertSilence(); + $this->udp->insertSilence(); $this->reset(); } @@ -1032,8 +979,8 @@ public function close(): void )); $this->userClose = true; - $this->client->close(); - $this->voiceWebsocket->close(); + $this->udp->close(); + $this->ws->close(); $this->heartbeatInterval = null; @@ -1042,11 +989,6 @@ public function close(): void $this->heartbeat = null; } - if (null !== $this->udpHeartbeat) { - $this->bot->loop->cancelTimer($this->udpHeartbeat); - $this->udpHeartbeat = null; - } - $this->seq = 0; $this->timestamp = 0; $this->sentLoginFrame = false; @@ -1066,15 +1008,12 @@ public function close(): void */ public function isSpeaking($id = null): bool { - if (! isset($id)) { - return $this->speaking; - } elseif ($user = $this->speakingStatus->get('user_id', $id)) { - return $user->speaking; - } elseif ($ssrc = $this->speakingStatus->get('ssrc', $id)) { - return $ssrc->speaking; - } - - return false; + return match(true) { + ! isset($id) => $this->speaking, + $user = $this->speakingStatus->get('user_id', $id) => $user->speaking, + $ssrc = $this->speakingStatus->get('ssrc', $id) => $ssrc->speaking, + default => false, + }; } /** @@ -1092,7 +1031,6 @@ public function isPaused(): bool * NOTE: This object contains the data as the VoiceStateUpdate Part. * @see \Discord\Parts\WebSockets\VoiceStateUpdate * - * * @param object $data The WebSocket data. */ public function handleVoiceStateUpdate(object $data): void @@ -1162,8 +1100,8 @@ public function getReceiveStream($id) } foreach ($this->speakingStatus as $status) { - if ($status->user_id == $id) { - return $this->receiveStreams[$status->ssrc]; + if ($status?->user_id == $id) { + return $this->receiveStreams[$status?->ssrc]; } } @@ -1218,7 +1156,7 @@ public function handleAudioData(Packet $voicePacket): void $this->createDecoder($ss); } - //$audioData = $decoder->stdin->write($voicePacket->getAudioData()); + $audioData = $decoder->stdin->write($voicePacket->getAudioData()); /* $buff = new Buffer(strlen($audioData) + 2); $buff->write(pack('s', strlen($audioData)), 0); @@ -1265,23 +1203,6 @@ protected function createDecoder($ss): void #$this->monitorProcessExit($decoder, $ss); } - - - protected function generateKeyPackage() - { - // Generate and return a new MLS key package - } - - protected function generateCommit() - { - // Generate and return an MLS commit message - } - - protected function generateWelcome() - { - // Generate and return an MLS welcome message - } - /** * Returns whether the voice client is ready. * @@ -1327,18 +1248,6 @@ public function getChannel(): Channel return $this->channel; } - /** - * Insert 5 frames of silence. - * - * @link https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation - */ - public function insertSilence(): void - { - while (--$this->silenceRemaining > 0) { - $this->sendBuffer(self::SILENCE_FRAME); - } - } - /** * Creates a new voice client instance statically * @@ -1362,7 +1271,7 @@ public static function make( ?VoiceManager &$manager = null, ): self { - return new static(...func_get_args()); + return new static($bot, $channel, $data, $deaf, $mute, $deferred, $manager); } /** @@ -1370,9 +1279,9 @@ public static function make( * * @return void */ - public function boot(): void + public function boot(): bool { - $this->once('ready', function () { + return $this->once('ready', function () { $this->bot->getLogger()->info('voice client is ready'); $this->manager->clients[$this->channel->guild_id] = $this; From b6727fcc1c23a7c177a73d723972d158af658e28 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 18:09:29 +0100 Subject: [PATCH 107/121] Removes unused function (already handled on either VoiceManager or VoiceClient class) --- src/Discord/Discord.php | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 900a9707d..f146b8cea 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -435,18 +435,7 @@ public static function __callStatic($method, $args) throw new \BadMethodCallException("Method {$method} does not exist in " . __CLASS__); } - /** - * Handles `VOICE_SERVER_UPDATE` packets. - * - * @param Payload $data Packet data. - */ - protected function handleVoiceServerUpdate(Payload $data): void - { - if (isset($this->voice->clients[$data->d->guild_id])) { - $this->logger->debug('voice server update received', ['guild' => $data->d->guild_id, 'data' => $data->d]); - $this->voice->clients[$data->d->guild_id]->handleVoiceServerChange((array) $data->d); - } - } + /** * Handles `RESUME` packets. From 9a7cf18fe1e45b54a5f52d4d32dbda3a5e5ed681 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 21:42:02 +0100 Subject: [PATCH 108/121] Fixes issue on heartbeat not working --- src/Discord/Voice/Client/WS.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/Client/WS.php b/src/Discord/Voice/Client/WS.php index fa0d0d9f2..d11009642 100644 --- a/src/Discord/Voice/Client/WS.php +++ b/src/Discord/Voice/Client/WS.php @@ -144,7 +144,7 @@ public function handleConnection(WebSocket $ws): void $this->vc->speakingStatus[$data->d->user_id] = $this->bot->getFactory()->create(VoiceSpeaking::class, $data->d); break; case Op::VOICE_HELLO: - $this->hbInterval = $data->d->heartbeat_interval; + $this->hbInterval = $this->vc->heartbeatInterval = $data->d->heartbeat_interval; $this->sendHeartbeat(); $this->heartbeat = $this->bot->loop->addPeriodicTimer( $this->hbInterval / 1000, From b3dc1ef8a2bef78b43ff99ec9065e8d063166c81 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Fri, 4 Jul 2025 21:42:31 +0100 Subject: [PATCH 109/121] Updates some visibilities on some properties --- src/Discord/Voice/Client/UDP.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index 259bceaf7..60cb181db 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -40,11 +40,11 @@ final class UDP extends Socket */ public int $streamTime = 0; - protected ?TimerInterface $heartbeat; + public ?TimerInterface $heartbeat; - protected $hbInterval; + public $hbInterval; - protected $hbSequence = 0; + protected int $hbSequence = 0; public string $ip; @@ -86,10 +86,15 @@ public function handleSsrcSending(): self public function handleHeartbeat(): self { - if (null === $this->hbInterval) { + if (empty($this->hbInterval)) { $this->hbInterval = $this->ws->vc->heartbeatInterval; } + if (null === loop()) { + logger()->error('No event loop found. Cannot handle heartbeat.'); + return $this; + } + $this->heartbeat = loop()->addPeriodicTimer( $this->hbInterval / 1000, function (): void { From 0c8bccf0a96103d36d2c715e19ffda7250e300dd Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Sun, 6 Jul 2025 23:12:37 +0100 Subject: [PATCH 110/121] [Voice] Adds FFI requirement Adds new class OpusFfi to use the Libopus from the system to decode the voice (BETA - still has bits of static) Updates ffmpeg decode parameters --- composer.json | 3 +- src/Discord/Voice/Client/Packet.php | 3 +- src/Discord/Voice/Client/UDP.php | 11 ++- src/Discord/Voice/Processes/DCA.php | 8 +- src/Discord/Voice/Processes/Ffmpeg.php | 33 +++++-- src/Discord/Voice/Processes/OpusFfi.php | 59 ++++++++++++ src/Discord/Voice/VoiceClient.php | 121 +++++++++++++++--------- 7 files changed, 172 insertions(+), 66 deletions(-) create mode 100644 src/Discord/Voice/Processes/OpusFfi.php diff --git a/composer.json b/composer.json index ab7a52124..64f340667 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,8 @@ "react/async": "^4.0 || ^3.0", "react/cache": "^0.5 || ^0.6 || ^1.0", "react/promise": "^3.0.0", - "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ext-ffi": "*" }, "require-dev": { "symfony/var-dumper": "*", diff --git a/src/Discord/Voice/Client/Packet.php b/src/Discord/Voice/Client/Packet.php index 42fddee5c..55bc1003e 100644 --- a/src/Discord/Voice/Client/Packet.php +++ b/src/Discord/Voice/Client/Packet.php @@ -195,6 +195,7 @@ public function decrypt(?string $message = null): string|false|null return null; } + // total message length $len = strlen($message); // 2. Extract the header @@ -236,7 +237,7 @@ public function decrypt(?string $message = null): string|false|null $this->log->warning('Failed to decode voice packet.', ['ssrc' => $this->ssrc]); } // Check if the message contains an extension and remove it - elseif (substr($message, 12, 2) === "\xBE\xDE") { + elseif (substr($message, 12, 2) === "\xbe\xde") { // Reads the 2 bytes after the extension identifier to get the extension length $extLengthData = substr($message, 14, 2); $headerExtensionLength = unpack('n', $extLengthData)[1]; diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index 60cb181db..fb36ee510 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -68,9 +68,14 @@ public function __construct($loop, $socket, $buffer = null, ?WS $ws = null) public function handleMessages(string $secret): self { - return $this->on('message', fn (string $message) => $this->ws->vc->handleAudioData( - new Packet($message, key: $secret) - )); + return $this->on('message', function (string $message) use ($secret) { + + if (strlen($message) <= 8) { + return; + } + + return $this->ws->vc->handleAudioData(new Packet($message, key: $secret)); + }); } public function handleSsrcSending(): self diff --git a/src/Discord/Voice/Processes/DCA.php b/src/Discord/Voice/Processes/DCA.php index 5275e6648..db8b37953 100644 --- a/src/Discord/Voice/Processes/DCA.php +++ b/src/Discord/Voice/Processes/DCA.php @@ -16,9 +16,9 @@ final class DCA extends ProcessAbstract */ public const DCA_VERSION = 'DCA1'; - protected static string $exec = '/usr/bin/dca'; + protected static string $exec = 'dca'; - public static function checkForFFmpeg(): bool + public static function checkForDca(): bool { $binaries = [ 'dca', @@ -80,10 +80,6 @@ public static function decode( } $flags = [ - '-ac', $channels, // Channels - '-ab', round($bitrate / 1000), // Bitrate - '-as', $frameSize, // Frame Size - '-mode', 'decode', // Decode mode ]; $flags = implode(' ', $flags); diff --git a/src/Discord/Voice/Processes/Ffmpeg.php b/src/Discord/Voice/Processes/Ffmpeg.php index 83ec8b22b..e4c3f7d5c 100644 --- a/src/Discord/Voice/Processes/Ffmpeg.php +++ b/src/Discord/Voice/Processes/Ffmpeg.php @@ -101,12 +101,28 @@ public static function decode( $frameSize = round(20 * 48); } + if ($filename) { + $filename = date('Y-m-d_H-i') . '-' . $filename; + if (!str_ends_with($filename, '.ogg')) { + $filename .= '.ogg'; + } + } elseif (null === $filename) { + $filename = 'pipe:1'; + } + $flags = [ - '-ac:opus', $channels, // Channels - '-ab', round($bitrate / 1000), // Bitrate - '-as', $frameSize, // Frame Size - '-ar', '48000', // Audio Rate - '-mode', 'decode', // Decode mode + '-loglevel', 'warning', // Set log level to warning to reduce output noise + '-channel_layout', 'stereo', + '-ac', $channels, + '-ar', '48000', + '-f', 's16le', + '-i', 'pipe:0', + '-acodec', 'libopus', + '-f', 'ogg', + '-ar', '48000', + '-ac', $channels, + '-b:a', $bitrate, + $filename ]; if (null !== $preArgs) { @@ -115,13 +131,12 @@ public static function decode( $flags = implode(' ', $flags); - return new Process( - self::$exec . " {$flags}", - fds: [ + return new Process(self::$exec . " {$flags}", + fds: str_contains(PHP_OS, 'Win') ? [ ['socket'], ['socket'], ['socket'], - ] + ] : [] ); } } diff --git a/src/Discord/Voice/Processes/OpusFfi.php b/src/Discord/Voice/Processes/OpusFfi.php new file mode 100644 index 000000000..60dc1f01e --- /dev/null +++ b/src/Discord/Voice/Processes/OpusFfi.php @@ -0,0 +1,59 @@ +new('int'); + $decoder = $ffi->opus_decoder_create($sample_rate, $channels, FFI::addr($error)); + + // Prepare input data (Opus-encoded) + $data_len = strlen($data); + + if ($data_len < 0) { + $ffi->opus_decoder_destroy($decoder); + return ''; + } + + // Prepare output buffer for PCM samples + $pcm = $ffi->new("short[". ($frame_size * $channels) ."]"); + + $data_buf = $ffi->new("uint8_t[$data_len]", false); + FFI::memcpy($data_buf, $data, $data_len); + + // Decode + $ret = $ffi->opus_decode($decoder, $data_buf, $data_len, $pcm, $frame_size, 0); + + if ($ret < 0) { + $ffi->opus_decoder_destroy($decoder); + return ''; // Or handle error + } + + // Get PCM bytes + $pcm_bytes = FFI::string($pcm, $ret * $channels * 2); // 2 bytes per sample + + // Clean up + $ffi->opus_decoder_destroy($decoder); + return $pcm_bytes; + } +} diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 65f67aba8..d5269daae 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -30,6 +30,7 @@ use Discord\Voice\Client\WS; use Discord\Voice\Processes\Dca; use Discord\Voice\Processes\Ffmpeg; +use Discord\Voice\Processes\OpusFfi; use Discord\Voice\ReceiveStream; use Discord\WebSockets\Op; use Discord\WebSockets\Payload; @@ -45,6 +46,9 @@ use React\Stream\ReadableResourceStream as Stream; use React\Stream\ReadableStreamInterface; +use function Discord\logger; +use function Discord\loop; + /** * The Discord voice client. * @@ -346,36 +350,6 @@ public function start(): bool return true; } - /** - * Handles a voice server change. - * - * @param array $data New voice server information. - */ - public function handleVoiceServerChange(Payload|array $data = []): void - { - $this->bot->logger->debug('voice server has changed, dynamically changing servers in the background', ['data' => $data]); - $this->reconnecting = true; - - $this->pause(); - - $this->udp->close(); - $this->ws->close(); - - $this->bot->loop->cancelTimer($this->heartbeat); - - $this->data['token'] = $data['token']; // set the token if it changed - $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - - $this->start(); - - $this->on('resumed', function () { - $this->bot->logger->debug('voice client resumed'); - $this->unpause(); - $this->speaking = false; - //$this->setSpeaking(true); - }); - } - /** * Plays a file/url on the voice stream. * @@ -858,8 +832,7 @@ public function setAudioApplication(string $app): void */ protected function mainSend($data): void { - $json = json_encode($data); - $this->bot->ws->send($json); + $this->bot->send($data); } /** @@ -978,9 +951,28 @@ public function close(): void ], )); + // Close processes for audio encoding + if (count($this->voiceDecoders) > 0) { + foreach ($this->voiceDecoders as $decoder) { + $decoder->close(); + } + } + + if (count($this->receiveStreams) > 0) { + foreach ($this->receiveStreams as $stream) { + $stream->close(); + } + } + + if (count($this->speakingStatus) > 0) { + foreach ($this->speakingStatus as $ss) { + $this->removeDecoder($ss); + } + } + $this->userClose = true; - $this->udp->close(); $this->ws->close(); + $this->udp->close(); $this->heartbeatInterval = null; @@ -1154,18 +1146,22 @@ public function handleAudioData(Packet $voicePacket): void } $this->createDecoder($ss); + $decoder = $this->voiceDecoders[$ss->ssrc] ?? null; } - $audioData = $decoder->stdin->write($voicePacket->getAudioData()); + if ($decoder->stdin->isWritable() === false) { + logger()->warning('Decoder stdin is not writable.', ['ssrc' => $ss->ssrc]); + return; // decoder stdin is not writable, cannot write audio data + } - /* $buff = new Buffer(strlen($audioData) + 2); - $buff->write(pack('s', strlen($audioData)), 0); - $buff->write($audioData, 2); + if ( + empty($voicePacket->decryptedAudio) + || $voicePacket->decryptedAudio === "\xf8\xff\xfe" + ) { + return; // no audio data to write + } - $stdinHandle = fopen($this->tempFiles['stdin'], 'a'); // Use append mode - fwrite($stdinHandle, (string) $buff); - fflush($stdinHandle); // Make sure the data is written immediately - fclose($stdinHandle); */ + $decoder->stdin->write(OpusFfi::decode($voicePacket->decryptedAudio)); } /** @@ -1175,16 +1171,17 @@ public function handleAudioData(Packet $voicePacket): void */ protected function createDecoder($ss): void { - $decoder = Ffmpeg::decode(); - $decoder->start($this->bot->loop); + $decoder = Ffmpeg::decode("$ss->ssrc"); + $decoder->start(loop()); $decoder->stdout->on('data', function ($data) use ($ss) { if (empty($data)) { return; // no data to process } - $this->receiveStreams[$ss->ssrc]->writePCM($data); - $this->receiveStreams[$ss->ssrc]->writeOpus($data); + logger()->debug('Received data from decoder.', ['ssrc' => $ss->ssrc, 'length' => strlen($data)]); + + $this->receiveStreams[$ss->ssrc]->write($data); }); $decoder->stderr->on('data', function ($data) use ($ss) { @@ -1200,7 +1197,39 @@ protected function createDecoder($ss): void $this->voiceDecoders[$ss->ssrc] = $decoder; // Monitor the process for exit - #$this->monitorProcessExit($decoder, $ss); + $this->monitorProcessExit($decoder, $ss); + } + + /** + * Monitor a process for exit and trigger callbacks when it exits + * + * @param Process $process The process to monitor + * @param object $ss The speaking status object + * @param callable $createDecoder Function to create a new decoder if needed + */ + protected function monitorProcessExit(Process $process, $ss): void + { + // Store the process ID + // $pid = $process->getPid(); + + // Check every second if the process is still running + $this->monitorProcessTimer = $this->bot->loop->addPeriodicTimer(1.0, function () use ($process, $ss) { + // Check if the process is still running + if (!$process->isRunning()) { + // Get the exit code + $exitCode = $process->getExitCode(); + + // Clean up the timer + $this->bot->loop->cancelTimer($this->monitorProcessTimer); + + // If exit code indicates an error, emit event and recreate decoder + if ($exitCode > 0) { + $this->emit('decoder-error', [$exitCode, null, $ss]); + unset($this->voiceDecoders[$ss->ssrc]); + $this->createDecoder($ss); + } + } + }); } /** From 82feaa584cbfb27c0d19e895dd5770dc8a7ae6ae Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 11:46:51 +0100 Subject: [PATCH 111/121] Fixes an issue where it threw an error (frame_size should be 960) --- src/Discord/Voice/Processes/OpusFfi.php | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Discord/Voice/Processes/OpusFfi.php b/src/Discord/Voice/Processes/OpusFfi.php index 60dc1f01e..83cfb0484 100644 --- a/src/Discord/Voice/Processes/OpusFfi.php +++ b/src/Discord/Voice/Processes/OpusFfi.php @@ -13,15 +13,18 @@ public static function decode($data): string // Load libopus and define needed functions/types $ffi = FFI::cdef(' typedef struct OpusDecoder OpusDecoder; - OpusDecoder *opus_decoder_create(int Fs, int channels, int *error); - int opus_decode(OpusDecoder *st, const unsigned char *data, int len, short *pcm, int frame_size, int decode_fec); + typedef short opus_int16; + typedef int opus_int32; + + OpusDecoder *opus_decoder_create(opus_int32 Fs, int channels, int *error); + int opus_decode(OpusDecoder *st, const unsigned char *data, opus_int32 len, opus_int16 *pcm, int frame_size, int decode_fec); void opus_decoder_destroy(OpusDecoder *st); ', 'libopus.so.0'); // Parameters $sample_rate = 48000; $channels = 2; - $frame_size = 920; // 20ms at 48kHz + $frame_size = 960; // 20ms at 48kHz // Create decoder $error = $ffi->new('int'); @@ -36,9 +39,9 @@ public static function decode($data): string } // Prepare output buffer for PCM samples - $pcm = $ffi->new("short[". ($frame_size * $channels) ."]"); + $pcm = $ffi->new("opus_int16[". ($frame_size * $channels) ."]"); - $data_buf = $ffi->new("uint8_t[$data_len]", false); + $data_buf = $ffi->new("const unsigned char[$data_len]", false); FFI::memcpy($data_buf, $data, $data_len); // Decode @@ -46,6 +49,7 @@ public static function decode($data): string if ($ret < 0) { $ffi->opus_decoder_destroy($decoder); + // TODO: Handle decoding error return ''; // Or handle error } From 6389d8d27d6b03b70fbc756e6a7480e640c044f5 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 15:49:38 +0100 Subject: [PATCH 112/121] Adds `->leave()` function to make the bot leave the voice channel Removes some dev/debugging logs Updates some functions to return VoiceClient instead of void Updates OpusFfi class to handle frames of the audio --- src/Discord/Voice/Client/Packet.php | 2 +- src/Discord/Voice/Processes/OpusFfi.php | 20 +++-- src/Discord/Voice/VoiceClient.php | 102 +++++++++++++----------- 3 files changed, 71 insertions(+), 53 deletions(-) diff --git a/src/Discord/Voice/Client/Packet.php b/src/Discord/Voice/Client/Packet.php index 55bc1003e..ca0318736 100644 --- a/src/Discord/Voice/Client/Packet.php +++ b/src/Discord/Voice/Client/Packet.php @@ -237,7 +237,7 @@ public function decrypt(?string $message = null): string|false|null $this->log->warning('Failed to decode voice packet.', ['ssrc' => $this->ssrc]); } // Check if the message contains an extension and remove it - elseif (substr($message, 12, 2) === "\xbe\xde") { + elseif (substr($message, 12, 2) === "\xBE\xDE") { // Reads the 2 bytes after the extension identifier to get the extension length $extLengthData = substr($message, 14, 2); $headerExtensionLength = unpack('n', $extLengthData)[1]; diff --git a/src/Discord/Voice/Processes/OpusFfi.php b/src/Discord/Voice/Processes/OpusFfi.php index 83cfb0484..db4bac229 100644 --- a/src/Discord/Voice/Processes/OpusFfi.php +++ b/src/Discord/Voice/Processes/OpusFfi.php @@ -16,6 +16,9 @@ public static function decode($data): string typedef short opus_int16; typedef int opus_int32; + int opus_packet_get_nb_frames(const unsigned char packet[], opus_int32 len); + int opus_packet_get_samples_per_frame(const unsigned char * data, opus_int32 Fs); + OpusDecoder *opus_decoder_create(opus_int32 Fs, int channels, int *error); int opus_decode(OpusDecoder *st, const unsigned char *data, opus_int32 len, opus_int16 *pcm, int frame_size, int decode_fec); void opus_decoder_destroy(OpusDecoder *st); @@ -24,14 +27,21 @@ public static function decode($data): string // Parameters $sample_rate = 48000; $channels = 2; - $frame_size = 960; // 20ms at 48kHz + + $data_len = strlen($data); + + $data_buf = $ffi->new("const unsigned char[$data_len]", false); + FFI::memcpy($data_buf, $data, $data_len); + + $frames = $ffi->opus_packet_get_nb_frames($data_buf, $data_len); + $samples_per_frame = $ffi->opus_packet_get_samples_per_frame($data_buf, $sample_rate); + $frame_size = $frames * $samples_per_frame; // Create decoder $error = $ffi->new('int'); $decoder = $ffi->opus_decoder_create($sample_rate, $channels, FFI::addr($error)); // Prepare input data (Opus-encoded) - $data_len = strlen($data); if ($data_len < 0) { $ffi->opus_decoder_destroy($decoder); @@ -39,10 +49,7 @@ public static function decode($data): string } // Prepare output buffer for PCM samples - $pcm = $ffi->new("opus_int16[". ($frame_size * $channels) ."]"); - - $data_buf = $ffi->new("const unsigned char[$data_len]", false); - FFI::memcpy($data_buf, $data, $data_len); + $pcm = $ffi->new("opus_int16[" . $frame_size * $channels * 2 . "]", false); // Decode $ret = $ffi->opus_decode($decoder, $data_buf, $data_len, $pcm, $frame_size, 0); @@ -58,6 +65,7 @@ public static function decode($data): string // Clean up $ffi->opus_decoder_destroy($decoder); + return $pcm_bytes; } } diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index d5269daae..6eba29a5c 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -70,20 +70,6 @@ class VoiceClient extends EventEmitter */ public $ready = false; - /** - * The DCA binary name that we will use. - * - * @var string|null The DCA binary name that will be run. - */ - public $dca; - - /** - * The FFmpeg binary location. - * - * @var string|null The FFmpeg binary location. - */ - public $ffmpeg; - /** * The voice WebSocket instance. * @@ -96,7 +82,7 @@ class VoiceClient extends EventEmitter * * @var null|Socket|\Discord\Voice\Client\UDP */ - public null|Socket|UDP $udp; + public null|UDP $udp; /** * The Voice WebSocket endpoint. @@ -286,13 +272,6 @@ class VoiceClient extends EventEmitter */ public array $clientsConnected = []; - /** - * Temporary files. - * - * @var array|null - */ - public $tempFiles; - /** @var TimerInterface */ public $monitorProcessTimer; @@ -303,6 +282,8 @@ class VoiceClient extends EventEmitter */ public array $users; + public $streamTime = 0; + /** * Constructs the Voice client instance * @@ -737,27 +718,47 @@ public function setSpeaking(bool $speaking = true): void /** * Switches voice channels. * - * @param Channel $channel The channel to switch to. + * @param null|Channel $channel The channel to switch to. * * @throws \InvalidArgumentException */ - public function switchChannel(Channel $channel): void + public function switchChannel(?Channel $channel): self { - if (! $channel->isVoiceBased()) { + if (isset($channel) && ! $channel->isVoiceBased()) { throw new \InvalidArgumentException("Channel must be a voice channel to be able to switch, given type {$channel->type}."); } + // We allow the user to switch to null, which will disconnect them from the voice channel. + if (! isset($channel)) { + $channel = $this->channel; + $this->userClose = true; + } else { + $this->channel = $channel; + } + $this->mainSend(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, [ 'guild_id' => $channel->guild_id, - 'channel_id' => $channel->id, + 'channel_id' => $channel?->id ?? null, 'self_mute' => $this->mute, 'self_deaf' => $this->deaf, ], )); - $this->channel = $channel; + return $this; + } + + /** + * Leaves the current voice channel. + * + * @return \Discord\Voice\VoiceClient + */ + public function leave(): static + { + $this->switchChannel(null); + + return $this; } /** @@ -936,21 +937,11 @@ public function close(): void if ($this->speaking) { $this->stop(); - #$this->setSpeaking(false); + $this->setSpeaking(false); } $this->ready = false; - $this->mainSend(VoicePayload::new( - Op::OP_VOICE_STATE_UPDATE, - [ - 'guild_id' => $this->channel->guild_id, - 'channel_id' => null, - 'self_mute' => true, - 'self_deaf' => true, - ], - )); - // Close processes for audio encoding if (count($this->voiceDecoders) > 0) { foreach ($this->voiceDecoders as $decoder) { @@ -970,6 +961,16 @@ public function close(): void } } + $this->mainSend(VoicePayload::new( + Op::OP_VOICE_STATE_UPDATE, + [ + 'guild_id' => $this->channel->guild_id, + 'channel_id' => null, + 'self_mute' => true, + 'self_deaf' => true, + ], + )); + $this->userClose = true; $this->ws->close(); $this->udp->close(); @@ -1151,17 +1152,26 @@ public function handleAudioData(Packet $voicePacket): void if ($decoder->stdin->isWritable() === false) { logger()->warning('Decoder stdin is not writable.', ['ssrc' => $ss->ssrc]); - return; // decoder stdin is not writable, cannot write audio data + return; // decoder stdin is not writable, cannot write audio data. + // This should be either restarted or checked if the decoder is still running. } if ( empty($voicePacket->decryptedAudio) - || $voicePacket->decryptedAudio === "\xf8\xff\xfe" + || $voicePacket->decryptedAudio === "\xf8\xff\xfe" // Opus silence frame + || strlen($voicePacket->decryptedAudio) < 8 // Opus frame is at least 8 bytes ) { return; // no audio data to write } - $decoder->stdin->write(OpusFfi::decode($voicePacket->decryptedAudio)); + $data = OpusFfi::decode($voicePacket->decryptedAudio); + + if (empty(trim($data))) { + logger()->debug('Received empty audio data.', ['ssrc' => $ss->ssrc]); + return; // no audio data to write + } + + $decoder->stdin->write($data); } /** @@ -1171,17 +1181,16 @@ public function handleAudioData(Packet $voicePacket): void */ protected function createDecoder($ss): void { - $decoder = Ffmpeg::decode("$ss->ssrc"); + $decoder = Ffmpeg::decode((string) $ss->ssrc); $decoder->start(loop()); $decoder->stdout->on('data', function ($data) use ($ss) { if (empty($data)) { - return; // no data to process + return; // no data to process, should be ignored } - logger()->debug('Received data from decoder.', ['ssrc' => $ss->ssrc, 'length' => strlen($data)]); - - $this->receiveStreams[$ss->ssrc]->write($data); + // Emit the decoded opus data + $this->receiveStreams[$ss->ssrc]->writeOpus($data); }); $decoder->stderr->on('data', function ($data) use ($ss) { @@ -1324,6 +1333,7 @@ public function boot(): bool $this->deferred->reject($e); }) ->once('close', function () { + $this->leave(); $this->bot->getLogger()->warning('voice client closed'); unset($this->manager->clients[$this->channel->guild_id]); }) From 6a762da487bb134af5617239e8ea254a248d1184 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 15:49:47 +0100 Subject: [PATCH 113/121] Adds return null on udp --- src/Discord/Voice/Client/UDP.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index fb36ee510..f2c378609 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -71,7 +71,7 @@ public function handleMessages(string $secret): self return $this->on('message', function (string $message) use ($secret) { if (strlen($message) <= 8) { - return; + return null; } return $this->ws->vc->handleAudioData(new Packet($message, key: $secret)); From 866da52347636e26c58a6817981078d3c7dfdf9a Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 15:51:39 +0100 Subject: [PATCH 114/121] Updates ffmpeg command, adds todo --- src/Discord/Voice/Processes/Ffmpeg.php | 27 ++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/Discord/Voice/Processes/Ffmpeg.php b/src/Discord/Voice/Processes/Ffmpeg.php index e4c3f7d5c..c243bcd2a 100644 --- a/src/Discord/Voice/Processes/Ffmpeg.php +++ b/src/Discord/Voice/Processes/Ffmpeg.php @@ -88,6 +88,23 @@ public static function encode( ); } + /** + * Decodes an Opus audio stream to OGG format using FFmpeg. + * + * TODO: Add support for Windows, currently only tested and ran on WSL2 + * + * @param mixed $filename If there's no name, it will output to stdout + * (pipe:1). If a name is given, it will save the file + * with the given name. If the name does not end with + * .ogg, it will append .ogg to the name. + * If null, it will use 'pipe:1' as the filename. + * @param int|float $volume Default: 0 + * @param int $bitrate Default: 128000 + * @param int $channels Default: 2 + * @param null|int $frameSize + * @param null|array $preArgs + * @return Process + */ public static function decode( ?string $filename = null, int|float $volume = 0, @@ -111,7 +128,7 @@ public static function decode( } $flags = [ - '-loglevel', 'warning', // Set log level to warning to reduce output noise + '-loglevel', 'error', // Set log level to warning to reduce output noise '-channel_layout', 'stereo', '-ac', $channels, '-ar', '48000', @@ -131,12 +148,6 @@ public static function decode( $flags = implode(' ', $flags); - return new Process(self::$exec . " {$flags}", - fds: str_contains(PHP_OS, 'Win') ? [ - ['socket'], - ['socket'], - ['socket'], - ] : [] - ); + return new Process(self::$exec . " {$flags}"); } } From 19b68e6254811cfe89de55838612fdae6593fb28 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 16:14:36 +0100 Subject: [PATCH 115/121] Runs pint on voice folder Adds sodium thrown exception on Voice\Packet class --- composer.json | 2 +- src/Discord/Voice/Client/Packet.php | 5 +++ src/Discord/Voice/Client/UDP.php | 8 ++-- src/Discord/Voice/Processes/DCA.php | 7 +--- src/Discord/Voice/Processes/Ffmpeg.php | 7 +--- src/Discord/Voice/VoiceClient.php | 56 +++----------------------- src/Discord/Voice/VoiceManager.php | 4 +- 7 files changed, 22 insertions(+), 67 deletions(-) diff --git a/composer.json b/composer.json index 64f340667..d5f394efc 100644 --- a/composer.json +++ b/composer.json @@ -58,7 +58,7 @@ "ext-fileinfo": "For function mime_content_type()." }, "scripts": { - "pint": ["./vendor/bin/pint --config ./pint.json ./src"], + "pint": ["./vendor/bin/pint --config ./pint.json"], "cs": ["./vendor/bin/php-cs-fixer fix"], "unit": ["./vendor/bin/phpunit --testdox"], "coverage": ["XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-html coverage --testdox"], diff --git a/src/Discord/Voice/Client/Packet.php b/src/Discord/Voice/Client/Packet.php index ca0318736..d8aecc958 100644 --- a/src/Discord/Voice/Client/Packet.php +++ b/src/Discord/Voice/Client/Packet.php @@ -16,6 +16,7 @@ use Discord\Helpers\ByteBuffer\Buffer; use Discord\Helpers\FormatPackEnum; use Monolog\Logger; + use function Discord\logger; /** @@ -113,6 +114,10 @@ public function __construct( protected ?string $key = null, protected ?Logger $log = null ) { + if (! function_exists('sodium_crypto_secretbox')) { + throw new LibSodiumNotFoundException('libsodium-php could not be found.'); + } + $this->unpack($data); if ($decrypt) { diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index f2c378609..ed8a92e3f 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -1,14 +1,16 @@ ws->vc->paused) { + if (! $this->ws->vc->paused) { // If the voice client is paused, we don't need to refresh the silence frames. return; } diff --git a/src/Discord/Voice/Processes/DCA.php b/src/Discord/Voice/Processes/DCA.php index db8b37953..054711f3f 100644 --- a/src/Discord/Voice/Processes/DCA.php +++ b/src/Discord/Voice/Processes/DCA.php @@ -4,7 +4,6 @@ namespace Discord\Voice\Processes; -use Discord\Voice\Processes\ProcessAbstract; use React\ChildProcess\Process; final class DCA extends ProcessAbstract @@ -54,8 +53,7 @@ public static function encode( int|float $volume = 0, int $bitrate = 128000, ?array $preArgs = null - ): Process - { + ): Process { $flags = [ '-ab', round($bitrate / 1000), // Bitrate '-mode', 'decode', // Decode mode @@ -73,8 +71,7 @@ public static function decode( int $channels = 2, ?int $frameSize = null, ?array $preArgs = null, - ): Process - { + ): Process { if (null === $frameSize) { $frameSize = round($frameSize * 48); } diff --git a/src/Discord/Voice/Processes/Ffmpeg.php b/src/Discord/Voice/Processes/Ffmpeg.php index c243bcd2a..2f175fee5 100644 --- a/src/Discord/Voice/Processes/Ffmpeg.php +++ b/src/Discord/Voice/Processes/Ffmpeg.php @@ -5,7 +5,6 @@ namespace Discord\Voice\Processes; use Discord\Exceptions\FFmpegNotFoundException; -use Discord\Voice\Processes\ProcessAbstract; use React\ChildProcess\Process; final class Ffmpeg extends ProcessAbstract @@ -56,8 +55,7 @@ public static function encode( int|float $volume = 0, int $bitrate = 128000, ?array $preArgs = null - ): Process - { + ): Process { $flags = [ '-i', $filename ?? 'pipe:0', '-map_metadata', '-1', @@ -112,8 +110,7 @@ public static function decode( int $channels = 2, ?int $frameSize = null, ?array $preArgs = null, - ): Process - { + ): Process { if (null === $frameSize) { $frameSize = round(20 * 48); } diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 6eba29a5c..56346e6b6 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -15,7 +15,6 @@ use Discord\Discord; use Discord\Exceptions\FileNotFoundException; -use Discord\Exceptions\LibSodiumNotFoundException; use Discord\Exceptions\OutdatedDCAException; use Discord\Exceptions\Voice\AudioAlreadyPlayingException; use Discord\Exceptions\Voice\ClientNotReadyException; @@ -31,7 +30,6 @@ use Discord\Voice\Processes\Dca; use Discord\Voice\Processes\Ffmpeg; use Discord\Voice\Processes\OpusFfi; -use Discord\Voice\ReceiveStream; use Discord\WebSockets\Op; use Discord\WebSockets\Payload; use Discord\WebSockets\VoicePayload; @@ -56,13 +54,6 @@ */ class VoiceClient extends EventEmitter { - /** - * The DCA version the client is using. - * - * @var string The DCA version. - */ - public const DCA_VERSION = 'DCA1'; - /** * Is the voice client ready? * @@ -105,13 +96,6 @@ class VoiceClient extends EventEmitter */ public $heartbeat; - /** - * The UDP heartbeat sequence. - * - * @var int The heartbeat sequence. - */ - public $heartbeatSeq = 0; - /** * The SSRC value. * @@ -235,15 +219,6 @@ class VoiceClient extends EventEmitter */ public $userClose = false; - /** - * The Discord voice gateway version. - * - * @see https://discord.com/developers/docs/topics/voice-connections#voice-gateway-versioning-gateway-versions - * - * @var int Voice version. - */ - public $version = 8; - /** * The Config for DNS Resolver. * @@ -320,10 +295,7 @@ public function __construct( */ public function start(): bool { - if ( - ! Ffmpeg::checkForFFmpeg() || - ! $this->checkForLibsodium() - ) { + if (! Ffmpeg::checkForFFmpeg()) { return false; } @@ -750,11 +722,11 @@ public function switchChannel(?Channel $channel): self } /** - * Leaves the current voice channel. + * Disconnects the bot from the current voice channel. * * @return \Discord\Voice\VoiceClient */ - public function leave(): static + public function disconnect(): static { $this->switchChannel(null); @@ -1251,22 +1223,6 @@ public function isReady(): bool return $this->ready; } - /** - * Checks if libsodium-php is installed. - * - * @return bool - */ - protected function checkForLibsodium(): bool - { - if (! function_exists('sodium_crypto_secretbox')) { - $this->emit('error', [new LibSodiumNotFoundException('libsodium-php could not be found.')]); - - return false; - } - - return true; - } - public function getDbVolume(): float|int { return match($this->volume) { @@ -1307,8 +1263,7 @@ public static function make( bool $mute = false, ?Deferred $deferred = null, ?VoiceManager &$manager = null, - ): self - { + ): self { return new static($bot, $channel, $data, $deaf, $mute, $deferred, $manager); } @@ -1329,11 +1284,12 @@ public function boot(): bool $this->deferred->resolve($this); }) ->once('error', function ($e) { + $this->disconnect(); $this->bot->getLogger()->error('error initializing voice client', ['e' => $e->getMessage()]); $this->deferred->reject($e); }) ->once('close', function () { - $this->leave(); + $this->disconnect(); $this->bot->getLogger()->warning('voice client closed'); unset($this->manager->clients[$this->channel->guild_id]); }) diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index 2ac392ef1..faa3ffe74 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -6,7 +6,6 @@ use Discord\Discord; use Discord\Parts\Channel\Channel; -use Discord\Voice\VoiceClient; use Discord\WebSockets\Event; use Discord\WebSockets\Op; use Discord\WebSockets\VoicePayload; @@ -41,8 +40,7 @@ public function createClientAndJoinChannel( Discord $discord, bool $mute = false, bool $deaf = true, - ) - { + ) { $deferred = new Deferred(); try { From 82a340f3565402f0738c2ab3e1cf3a781b41dd1f Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 16:53:13 +0100 Subject: [PATCH 116/121] Adds new exceptions --- .../Voice/AudioAlreadyPlayingException.php | 2 +- .../CantJoinMoreThanOneChannelException.php | 25 +++++++++++++++++++ .../Voice/CantSpeakInChannelException.php | 25 +++++++++++++++++++ .../Voice/ChannelMustAllowVoiceException.php | 25 +++++++++++++++++++ .../Voice/ClientNotReadyException.php | 2 +- .../Voice/EnterChannelDeniedException.php | 25 +++++++++++++++++++ src/Discord/Voice/VoiceManager.php | 15 +++++++---- 7 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 src/Discord/Exceptions/Voice/CantJoinMoreThanOneChannelException.php create mode 100644 src/Discord/Exceptions/Voice/CantSpeakInChannelException.php create mode 100644 src/Discord/Exceptions/Voice/ChannelMustAllowVoiceException.php create mode 100644 src/Discord/Exceptions/Voice/EnterChannelDeniedException.php diff --git a/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php b/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php index 5eabe4354..edbbc0cf7 100644 --- a/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php +++ b/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php @@ -16,7 +16,7 @@ * * @since 10.0.0 */ -class AudioAlreadyPlayingException extends \RuntimeException +final class AudioAlreadyPlayingException extends \RuntimeException { public function __construct(?string $message = null) { diff --git a/src/Discord/Exceptions/Voice/CantJoinMoreThanOneChannelException.php b/src/Discord/Exceptions/Voice/CantJoinMoreThanOneChannelException.php new file mode 100644 index 000000000..950679ed2 --- /dev/null +++ b/src/Discord/Exceptions/Voice/CantJoinMoreThanOneChannelException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the selected Channel does not allow voice. + * + * @since 10.0.0 + */ +final class CantJoinMoreThanOneChannelException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'You cannot join more than one voice channel per guild/server.'); + } +} diff --git a/src/Discord/Exceptions/Voice/CantSpeakInChannelException.php b/src/Discord/Exceptions/Voice/CantSpeakInChannelException.php new file mode 100644 index 000000000..cc41fce7e --- /dev/null +++ b/src/Discord/Exceptions/Voice/CantSpeakInChannelException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the selected Channel does not allow voice. + * + * @since 10.0.0 + */ +final class CantSpeakInChannelException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'The current Channel doesn\'t have proper permissions for the Bot to speak in it.'); + } +} diff --git a/src/Discord/Exceptions/Voice/ChannelMustAllowVoiceException.php b/src/Discord/Exceptions/Voice/ChannelMustAllowVoiceException.php new file mode 100644 index 000000000..7c759c6e4 --- /dev/null +++ b/src/Discord/Exceptions/Voice/ChannelMustAllowVoiceException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the selected Channel does not allow voice. + * + * @since 10.0.0 + */ +final class ChannelMustAllowVoiceException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'Current Channel must allow voice.'); + } +} diff --git a/src/Discord/Exceptions/Voice/ClientNotReadyException.php b/src/Discord/Exceptions/Voice/ClientNotReadyException.php index 4b1451b31..dbee4dbcb 100644 --- a/src/Discord/Exceptions/Voice/ClientNotReadyException.php +++ b/src/Discord/Exceptions/Voice/ClientNotReadyException.php @@ -16,7 +16,7 @@ * * @since 10.0.0 */ -class ClientNotReadyException extends \RuntimeException +final class ClientNotReadyException extends \RuntimeException { public function __construct(?string $message = null) { diff --git a/src/Discord/Exceptions/Voice/EnterChannelDeniedException.php b/src/Discord/Exceptions/Voice/EnterChannelDeniedException.php new file mode 100644 index 000000000..bab12804a --- /dev/null +++ b/src/Discord/Exceptions/Voice/EnterChannelDeniedException.php @@ -0,0 +1,25 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Exceptions\Voice; + +/** + * Thrown when the selected Channel does not allow voice. + * + * @since 10.0.0 + */ +final class EnterChannelDeniedException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'The current Channel doesn\'t have proper permissions for the Bot to connect to it.'); + } +} diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index faa3ffe74..7a5bf3150 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -5,6 +5,11 @@ namespace Discord\Voice; use Discord\Discord; +use Discord\Exceptions\Voice\CantJoinMoreThanOneChannelException; +use Discord\Exceptions\Voice\CantSpeakInChannelException; +use Discord\Exceptions\Voice\ChannelMustAllowVoiceException; +use Discord\Exceptions\Voice\ClientMustAllowVoiceException; +use Discord\Exceptions\Voice\EnterChannelDeniedException; use Discord\Parts\Channel\Channel; use Discord\WebSockets\Event; use Discord\WebSockets\Op; @@ -33,7 +38,7 @@ public function __construct( * @param \Discord\Discord $discord * @param bool $mute * @param bool $deaf - * @return \React\Promise\PromiseInterface + * @return \React\Promise\PromiseInterface */ public function createClientAndJoinChannel( Channel $channel, @@ -45,19 +50,19 @@ public function createClientAndJoinChannel( try { if (! $channel->isVoiceBased()) { - throw new \RuntimeException('Channel must allow voice.'); + throw new ChannelMustAllowVoiceException(); } if (! $channel->canJoin()) { - throw new \RuntimeException('The bot must have proper permissions to join this channel.'); + throw new EnterChannelDeniedException(); } if (! $channel->canSpeak() && ! $mute) { - throw new \RuntimeException('The bot must have permission to speak in this channel.'); + throw new CantSpeakInChannelException(); } if (isset($this->clients[$channel->guild_id])) { - throw new \RuntimeException('You cannot join more than one voice channel per guild.'); + throw new CantJoinMoreThanOneChannelException(); } } catch (\Throwable $th) { $deferred->reject($th); From 05e7a8d35a659ae447962ca4c247458a5946b6d4 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 16:54:06 +0100 Subject: [PATCH 117/121] Adds 2 new functions `->record()` and `->stopRecording()` (BETA) Adds 1 new property `->shouldRecord` to check whether the UDP messages should be recorded or not, set to `false` by default --- src/Discord/Voice/VoiceClient.php | 139 +++++++++++++++++++----------- 1 file changed, 89 insertions(+), 50 deletions(-) diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 56346e6b6..87aed74e3 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -59,7 +59,7 @@ class VoiceClient extends EventEmitter * * @var bool Whether the voice client is ready. */ - public $ready = false; + public bool $ready = false; /** * The voice WebSocket instance. @@ -73,84 +73,84 @@ class VoiceClient extends EventEmitter * * @var null|Socket|\Discord\Voice\Client\UDP */ - public null|UDP $udp; + public ?UDP $udp; /** * The Voice WebSocket endpoint. * * @var string|null The endpoint the Voice WebSocket and UDP client will connect to. */ - public $endpoint; + public ?string $endpoint; /** * The UDP heartbeat interval. * * @var int|null How often we send a heartbeat packet. */ - public $heartbeatInterval; + public ?int $heartbeatInterval; /** * The Voice WebSocket heartbeat timer. * * @var TimerInterface|null The heartbeat periodic timer. */ - public $heartbeat; + public ?TimerInterface $heartbeat; /** * The SSRC value. * * @var int|null The SSRC value used for RTP. */ - public $ssrc; + public ?int $ssrc; /** * The sequence of audio packets being sent. * * @var int The sequence of audio packets. */ - public $seq = 0; + public ?int $seq = 0; /** * The timestamp of the last packet. * * @var int The timestamp the last packet was constructed. */ - public $timestamp = 0; + public ?int $timestamp = 0; /** - * Are we currently set as speaking? + * Are we currently speaking? * - * @var bool Whether we are speaking or not. + * @var bool */ - public $speaking = false; + public bool $speaking = false; /** * Whether the voice client is currently paused. * - * @var bool Whether the voice client is currently paused. + * @var bool */ - public $paused = false; + public bool $paused = false; /** * Have we sent the login frame yet? * * @var bool Whether we have sent the login frame. */ - public $sentLoginFrame = false; + public bool $sentLoginFrame = false; /** * The time we started sending packets. * * @var float|int|null The time we started sending packets. */ - public $startTime; + public null|float|int $startTime; /** * The size of audio frames, in milliseconds. * * @var int The size of audio frames. */ - public $frameSize = 20; + public int $frameSize = 20; /** * Collection of the status of people speaking. @@ -173,21 +173,21 @@ class VoiceClient extends EventEmitter * * @var array|null Voice audio recieve streams. */ - public $recieveStreams; + public ?array $recieveStreams; /** * Voice audio receive streams. * * @var array|null Voice audio recieve streams. */ - public $receiveStreams; + public ?array $receiveStreams; /** * The volume the audio will be encoded with. * * @var int The volume that the audio will be encoded in. */ - protected $volume = 100; + protected int $volume = 100; /** * The audio application to encode with. @@ -196,49 +196,49 @@ class VoiceClient extends EventEmitter * * @var string The audio application. */ - protected $audioApplication = 'audio'; + protected string $audioApplication = 'audio'; /** * The bitrate to encode with. * * @var int Encoding bitrate. */ - protected $bitrate = 128000; + protected int $bitrate = 128000; /** * Is the voice client reconnecting? * * @var bool Whether the voice client is reconnecting. */ - public $reconnecting = false; + public bool $reconnecting = false; /** * Is the voice client being closed by user? * * @var bool Whether the voice client is being closed by user. */ - public $userClose = false; + public bool $userClose = false; /** * The Config for DNS Resolver. * * @var Config|string|null */ - public $dnsConfig; + public null|string|Config $dnsConfig; /** * readopus Timer. * * @var TimerInterface Timer */ - public $readOpusTimer; + public TimerInterface $readOpusTimer; /** * Audio Buffer. * * @var RealBuffer|null The Audio Buffer */ - public $buffer; + public null|RealBuffer $buffer; /** * Current clients connected to the voice chat @@ -247,7 +247,9 @@ class VoiceClient extends EventEmitter */ public array $clientsConnected = []; - /** @var TimerInterface */ + /** + * @var TimerInterface + */ public $monitorProcessTimer; /** @@ -257,7 +259,19 @@ class VoiceClient extends EventEmitter */ public array $users; - public $streamTime = 0; + /** + * Time in which the streaming started. + * + * @var int + */ + public int $streamTime = 0; + + /** + * Whether the current voice client is enabled to record audio. + * + * @var bool + */ + protected bool $shouldRecord = false; /** * Constructs the Voice client instance @@ -450,7 +464,7 @@ public function playOggStream($stream): PromiseInterface $loops = 0; - #$this->setSpeaking(true); + $this->setSpeaking(true); OggStream::fromBuffer($this->buffer)->then(function (OggStream $os) use ($deferred, &$ogg, &$loops) { $ogg = $os; @@ -509,7 +523,7 @@ protected function readOggOpus(Deferred $deferred, OggStream &$ogg, int &$loops) $delay = $nextTime - microtime(true); $this->readOpusTimer = $this->bot->loop->addTimer($delay, fn () => $this->readOggOpus($deferred, $ogg, $loops)); - }, function ($e) use ($deferred) { + }, function () use ($deferred) { $this->reset(); $deferred->resolve(null); }); @@ -569,7 +583,7 @@ public function playDCAStream($stream): PromiseInterface $this->buffer->write($d); }); - #$this->setSpeaking(true); + $this->setSpeaking(true); // Read magic byte header $this->buffer->read(4)->then(function ($mb) { @@ -651,7 +665,7 @@ protected function reset(): void $this->readOpusTimer = null; } - #$this->setSpeaking(false); + $this->setSpeaking(false); $this->streamTime = 0; $this->startTime = 0; $this->paused = false; @@ -981,16 +995,6 @@ public function isSpeaking($id = null): bool }; } - /** - * Checks if we are paused. - * - * @return bool Whether we are paused. - */ - public function isPaused(): bool - { - return $this->paused; - } - /** * Handles a voice state update. * NOTE: This object contains the data as the VoiceStateUpdate Part. @@ -1076,18 +1080,20 @@ public function getReceiveStream($id) /** * Handles raw opus data from the UDP server. * - * @param string $message The data from the UDP server. + * @param Packet $voicePacket The data from the UDP server. */ public function handleAudioData(Packet $voicePacket): void { + if (! $this->shouldRecord) { + // If we are not recording, we don't need to handle audio data. + return; + } + $message = $voicePacket?->decryptedAudio ?? null; - if (! $message) { - if (! $this->speakingStatus->get('ssrc', $voicePacket->getSSRC())) { - // We don't have a speaking status for this SSRC - // Probably a "ping" to the udp socket - return; - } + if (! $message || ! $this->speakingStatus->get('ssrc', $voicePacket->getSSRC())) { + // We don't have a speaking status for this SSRC + // Probably a "ping" to the udp socket // There's no message or the message threw an error inside the decrypt function $this->bot->logger->warning('No audio data.', ['voicePacket' => $voicePacket]); return; @@ -1270,7 +1276,7 @@ public static function make( /** * Boots the voice client and sets up event listeners. * - * @return void + * @return bool */ public function boot(): bool { @@ -1295,4 +1301,37 @@ public function boot(): bool }) ->start(); } + + public function record(): void + { + if ($this->shouldRecord) { + throw new \RuntimeException('Already recording audio.'); + } + + $this->shouldRecord = true; + $this->bot->getLogger()->info('Started recording audio.'); + + $this->udp->on('message', [$this, 'handleAudioData']); + } + + public function stopRecording(): void + { + if (! $this->shouldRecord) { + throw new \RuntimeException('Not recording audio.'); + } + + $this->shouldRecord = false; + $this->bot->getLogger()->info('Stopped recording audio.'); + + $this->udp->removeListener('message', [$this, 'handleAudioData']); + $this->reset(); + + foreach ($this->voiceDecoders as $decoder) { + $decoder->close(); + } + + $this->voiceDecoders = []; + $this->receiveStreams = []; + $this->speakingStatus = Collection::for(VoiceSpeaking::class, 'ssrc'); + } } From a60fad6a528f8d017363c53e043244bb8331aec5 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 16:54:20 +0100 Subject: [PATCH 118/121] Adds some doc blocks --- src/Discord/Discord.php | 10 +++- src/Discord/Voice/Client/UDP.php | 74 ++++++++++++++++++++++++- src/Discord/Voice/Processes/OpusFfi.php | 32 +++++++---- 3 files changed, 100 insertions(+), 16 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index f146b8cea..418fa2f48 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -425,7 +425,13 @@ public function __construct(array $options = []) } } - # BETA - still testing if it works + /** + * Resolves the called methods through the already created Discord instance. + * + * @param array $options Array of options. + * + * @return mixed + */ public static function __callStatic($method, $args) { if (method_exists(self::class, $method)) { @@ -435,8 +441,6 @@ public static function __callStatic($method, $args) throw new \BadMethodCallException("Method {$method} does not exist in " . __CLASS__); } - - /** * Handles `RESUME` packets. * diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index ed8a92e3f..e47485410 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -42,18 +42,51 @@ final class UDP extends Socket */ public int $streamTime = 0; + /** + * Current heartbeat timer. + */ public ?TimerInterface $heartbeat; - public $hbInterval; + /** + * Heartbeat interval in milliseconds. + * The interval at which the heartbeat is sent. + */ + public int $hbInterval; + /** + * Heartbeat sequence number. + * This is used to keep track of the heartbeat messages sent. + */ protected int $hbSequence = 0; + /** + * The IP address of the UDP server. + * + * @var string The IP address we are connected to. + */ public string $ip; + /** + * The port of the UDP server. + * + * @var int The port we are connected to. + */ public int $port; + /** + * The SSRC (Synchronization Source) identifier. + * This is used to identify the source of the audio stream. + * + * @var int The SSRC we are using for the voice connection. + */ public int $ssrc; + /** + * @param \React\EventLoop\LoopInterface $loop + * @param resource $socket + * @param null|Buffer$buffer + * @param null|WS $ws + */ public function __construct($loop, $socket, $buffer = null, ?WS $ws = null) { parent::__construct($loop, $socket, $buffer); @@ -68,6 +101,14 @@ public function __construct($loop, $socket, $buffer = null, ?WS $ws = null) } } + /** + * Handles incoming messages from the UDP server. + * This is where we handle the audio data received from the server. + * + * @param string $secret The secret key used to decrypt the audio data. + * + * @return UDP + */ public function handleMessages(string $secret): self { return $this->on('message', function (string $message) use ($secret) { @@ -76,10 +117,20 @@ public function handleMessages(string $secret): self return null; } + if ($this->ws->vc->deaf) { + return null; + } + return $this->ws->vc->handleAudioData(new Packet($message, key: $secret)); }); } + /** + * Handles the sending of the SSRC to the server. + * This is necessary for the server to know which SSRC we are using. + * + * @return UDP + */ public function handleSsrcSending(): self { $buffer = new Buffer(74); @@ -91,6 +142,12 @@ public function handleSsrcSending(): self return $this; } + /** + * Handles the heartbeat for the UDP client. + * To keep the connection open and responsive. + * + * @return UDP + */ public function handleHeartbeat(): self { if (empty($this->hbInterval)) { @@ -121,8 +178,11 @@ function (): void { } /** - * Decodes the UDP message once. + * Decodes the first UDP message received from the server. + * To discover which IP and port we should connect to. + * * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery + * * @return UDP */ public function decodeOnce(): self @@ -163,6 +223,11 @@ public function decodeOnce(): self }); } + /** + * * Handles errors that occur during UDP communication. + * + * @return UDP + */ public function handleErrors(): self { return $this->on('error', function (\Throwable $e): void { @@ -209,6 +274,11 @@ public function sendBuffer(string $data): void $this->ws->vc->emit('packet-sent', [$packet]); } + /** + * Closes the UDP client and cancels the heartbeat timer. + * + * @return void + */ public function close(): void { if ($this->heartbeat) { diff --git a/src/Discord/Voice/Processes/OpusFfi.php b/src/Discord/Voice/Processes/OpusFfi.php index db4bac229..040ecebf7 100644 --- a/src/Discord/Voice/Processes/OpusFfi.php +++ b/src/Discord/Voice/Processes/OpusFfi.php @@ -8,9 +8,19 @@ final class OpusFfi { + /** + * Creates a FFI instance (code in C) to decode Opus audio data. + * By using the libopus library, this function decodes Opus-encoded audio data + * into PCM samples. + * This is useful for processing audio data in Discord voice channels. + * @param string|mixed $data + * + * @return string Returns the decoded PCM audio data as a string/binary. + */ public static function decode($data): string { // Load libopus and define needed functions/types + // TODO: Move this to a separate file or class if needed. $ffi = FFI::cdef(' typedef struct OpusDecoder OpusDecoder; typedef short opus_int16; @@ -25,34 +35,34 @@ public static function decode($data): string ', 'libopus.so.0'); // Parameters - $sample_rate = 48000; + $sampleRate = 48000; $channels = 2; - $data_len = strlen($data); + $dataLength = strlen($data); - $data_buf = $ffi->new("const unsigned char[$data_len]", false); - FFI::memcpy($data_buf, $data, $data_len); + $dataBuffer = $ffi->new("const unsigned char[$dataLength]", false); + FFI::memcpy($dataBuffer, $data, $dataLength); - $frames = $ffi->opus_packet_get_nb_frames($data_buf, $data_len); - $samples_per_frame = $ffi->opus_packet_get_samples_per_frame($data_buf, $sample_rate); - $frame_size = $frames * $samples_per_frame; + $frames = $ffi->opus_packet_get_nb_frames($dataBuffer, $dataLength); + $samplesPerFrame = $ffi->opus_packet_get_samples_per_frame($dataBuffer, $sampleRate); + $frameSize = $frames * $samplesPerFrame; // Create decoder $error = $ffi->new('int'); - $decoder = $ffi->opus_decoder_create($sample_rate, $channels, FFI::addr($error)); + $decoder = $ffi->opus_decoder_create($sampleRate, $channels, FFI::addr($error)); // Prepare input data (Opus-encoded) - if ($data_len < 0) { + if ($dataLength < 0) { $ffi->opus_decoder_destroy($decoder); return ''; } // Prepare output buffer for PCM samples - $pcm = $ffi->new("opus_int16[" . $frame_size * $channels * 2 . "]", false); + $pcm = $ffi->new("opus_int16[" . $frameSize * $channels * 2 . "]", false); // Decode - $ret = $ffi->opus_decode($decoder, $data_buf, $data_len, $pcm, $frame_size, 0); + $ret = $ffi->opus_decode($decoder, $dataBuffer, $dataLength, $pcm, $frameSize, 0); if ($ret < 0) { $ffi->opus_decoder_destroy($decoder); From f69bb35edf6aea6a24e81646bfbf839b139410c5 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 18:33:53 +0100 Subject: [PATCH 119/121] Adds some docblock --- src/Discord/Voice/VoiceManager.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index 7a5bf3150..ab332bdde 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -17,6 +17,12 @@ use Evenement\EventEmitterTrait; use React\Promise\Deferred; +/** + * Manages voice clients for the Discord bot. + * + * @requires libopus - Linux | NOT TESTED - WINDOWS + * @requires FFMPEG - Linux | NOT TESTED - WINDOWS + */ final class VoiceManager { use EventEmitterTrait; From d1abf149decae2225a729acebda508e5a4a47cd8 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 7 Jul 2025 18:37:46 +0100 Subject: [PATCH 120/121] Updates @since for 10.19.0 --- src/Discord/Voice/Client/HeaderValuesEnum.php | 5 +++++ src/Discord/Voice/Client/Packet.php | 2 +- src/Discord/Voice/Client/UDP.php | 7 +++++++ src/Discord/Voice/Client/User.php | 3 +++ src/Discord/Voice/Client/WS.php | 8 ++++++++ src/Discord/Voice/Processes/DCA.php | 5 +++++ src/Discord/Voice/Processes/Ffmpeg.php | 5 +++++ src/Discord/Voice/Processes/OpusFfi.php | 5 +++++ src/Discord/Voice/Processes/ProcessAbstract.php | 7 +++++++ src/Discord/Voice/ReceiveStream.php | 3 +-- src/Discord/Voice/VoiceClient.php | 2 +- src/Discord/Voice/VoiceManager.php | 2 ++ 12 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/Discord/Voice/Client/HeaderValuesEnum.php b/src/Discord/Voice/Client/HeaderValuesEnum.php index cb1469905..e86e684ae 100644 --- a/src/Discord/Voice/Client/HeaderValuesEnum.php +++ b/src/Discord/Voice/Client/HeaderValuesEnum.php @@ -4,6 +4,11 @@ namespace Discord\Voice\Client; +/** + * Enum for header values used in Discord voice client. + * + * @since 10.19.0 + */ enum HeaderValuesEnum: int { case RTP_HEADER_OR_NONCE_LENGTH = 12; diff --git a/src/Discord/Voice/Client/Packet.php b/src/Discord/Voice/Client/Packet.php index d8aecc958..c61ba8d23 100644 --- a/src/Discord/Voice/Client/Packet.php +++ b/src/Discord/Voice/Client/Packet.php @@ -26,7 +26,7 @@ * packets. Check out their repo: * https://github.com/DV8FromTheWorld/JDA * - * @since 3.2.0 + * @since 10.19.0 */ class Packet { diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index e47485410..751d632b9 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -12,6 +12,13 @@ use function Discord\logger; use function Discord\loop; +/** + * Handles the UDP connection & events for Discord voice. + * This class manages the UDP socket for sending and receiving audio data, + * handling heartbeats, and managing the voice connection state. + * + * @since 10.19.0 + */ final class UDP extends Socket { /** diff --git a/src/Discord/Voice/Client/User.php b/src/Discord/Voice/Client/User.php index 88834260d..2fec6ed7c 100644 --- a/src/Discord/Voice/Client/User.php +++ b/src/Discord/Voice/Client/User.php @@ -10,6 +10,9 @@ use Discord\Voice\VoiceClient; use React\ChildProcess\Process; +/** + * @since 10.19.0 + */ final class User { public function __construct( diff --git a/src/Discord/Voice/Client/WS.php b/src/Discord/Voice/Client/WS.php index d11009642..f51999add 100644 --- a/src/Discord/Voice/Client/WS.php +++ b/src/Discord/Voice/Client/WS.php @@ -17,6 +17,14 @@ use React\EventLoop\TimerInterface; use React\Promise\PromiseInterface; +/** + * Handles the Discord voice WebSocket connection. + * + * This class manages the WebSocket connection to the Discord voice gateway, + * handling events, sending messages, and managing the voice connection state. + * + * @since 10.19.0 + */ final class WS { protected WebSocket $socket; diff --git a/src/Discord/Voice/Processes/DCA.php b/src/Discord/Voice/Processes/DCA.php index 054711f3f..3cbd1827e 100644 --- a/src/Discord/Voice/Processes/DCA.php +++ b/src/Discord/Voice/Processes/DCA.php @@ -6,6 +6,11 @@ use React\ChildProcess\Process; +/** + * Handles the encoding and decoding of audio streams using DCA format. + * + * @since 10.19.0 + */ final class DCA extends ProcessAbstract { /** diff --git a/src/Discord/Voice/Processes/Ffmpeg.php b/src/Discord/Voice/Processes/Ffmpeg.php index 2f175fee5..c60a54778 100644 --- a/src/Discord/Voice/Processes/Ffmpeg.php +++ b/src/Discord/Voice/Processes/Ffmpeg.php @@ -7,6 +7,11 @@ use Discord\Exceptions\FFmpegNotFoundException; use React\ChildProcess\Process; +/** + * Handles the decoding and encoding of audio streams using FFmpeg. + * + * @since 10.19.0 + */ final class Ffmpeg extends ProcessAbstract { protected static string $exec = '/usr/bin/ffmpeg'; diff --git a/src/Discord/Voice/Processes/OpusFfi.php b/src/Discord/Voice/Processes/OpusFfi.php index 040ecebf7..354fc2b6c 100644 --- a/src/Discord/Voice/Processes/OpusFfi.php +++ b/src/Discord/Voice/Processes/OpusFfi.php @@ -6,6 +6,11 @@ use FFI; +/** + * Handles the decoding of Opus audio data using FFI (Foreign Function Interface). + * + * @since 10.19.0 + */ final class OpusFfi { /** diff --git a/src/Discord/Voice/Processes/ProcessAbstract.php b/src/Discord/Voice/Processes/ProcessAbstract.php index f02a80e7c..8cae5ffeb 100644 --- a/src/Discord/Voice/Processes/ProcessAbstract.php +++ b/src/Discord/Voice/Processes/ProcessAbstract.php @@ -6,6 +6,13 @@ use React\ChildProcess\Process; +/** + * Abstract class for handling audio processing in Discord voice. + * + * This class provides methods to encode and decode audio streams using different processes. + * + * @since 10.19.0 + */ abstract class ProcessAbstract { abstract public static function encode( diff --git a/src/Discord/Voice/ReceiveStream.php b/src/Discord/Voice/ReceiveStream.php index 7cbb4faf8..128f0ad3a 100644 --- a/src/Discord/Voice/ReceiveStream.php +++ b/src/Discord/Voice/ReceiveStream.php @@ -16,8 +16,7 @@ /** * Handles recieving audio from Discord. * - * @since 10.5.0 The class was renamed to ReceiveStream. - * @since 3.2.0 + * @since 10.19.0 The class was renamed to ReceiveStream. */ class ReceiveStream extends RecieveStream { diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 87aed74e3..a28381346 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -50,7 +50,7 @@ /** * The Discord voice client. * - * @since 3.2.0 + * @since 10.19.0 */ class VoiceClient extends EventEmitter { diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index ab332bdde..ed9af821f 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -22,6 +22,8 @@ * * @requires libopus - Linux | NOT TESTED - WINDOWS * @requires FFMPEG - Linux | NOT TESTED - WINDOWS + * + * @since 10.19.0 */ final class VoiceManager { From e83649d41e8778210246872ac4f6fc2e818281f8 Mon Sep 17 00:00:00 2001 From: alexandre433 Date: Mon, 11 Aug 2025 18:41:52 +0100 Subject: [PATCH 121/121] Refactors voice client management and updates method names for clarity --- src/Discord/Discord.php | 2 +- src/Discord/Voice/Client/Packet.php | 83 ++++---------- src/Discord/Voice/Client/UDP.php | 43 ++------ src/Discord/Voice/Client/WS.php | 72 +++++++++--- src/Discord/Voice/Processes/Ffmpeg.php | 10 +- .../Voice/Processes/ProcessAbstract.php | 18 ++- src/Discord/Voice/VoiceClient.php | 75 +++++++------ src/Discord/Voice/VoiceManager.php | 103 +++++++++++++----- 8 files changed, 219 insertions(+), 187 deletions(-) diff --git a/src/Discord/Discord.php b/src/Discord/Discord.php index 418fa2f48..e6ede7b3a 100644 --- a/src/Discord/Discord.php +++ b/src/Discord/Discord.php @@ -1297,7 +1297,7 @@ public function getVoiceClient(string|int $guildId): ?VoiceClient */ public function joinVoiceChannel(Channel $channel, $mute = false, $deaf = true, ?LoggerInterface $logger = null): PromiseInterface { - return $this->voice->createClientAndJoinChannel($channel, $this, $mute, $deaf); + return $this->voice->joinChannel($channel, $this, $mute, $deaf); } /** diff --git a/src/Discord/Voice/Client/Packet.php b/src/Discord/Voice/Client/Packet.php index c61ba8d23..e49acc6f2 100644 --- a/src/Discord/Voice/Client/Packet.php +++ b/src/Discord/Voice/Client/Packet.php @@ -13,6 +13,7 @@ namespace Discord\Voice\Client; +use Discord\Exceptions\LibSodiumNotFoundException; use Discord\Helpers\ByteBuffer\Buffer; use Discord\Helpers\FormatPackEnum; use Monolog\Logger; @@ -28,72 +29,54 @@ * * @since 10.19.0 */ -class Packet +final class Packet { /** * The audio header, in binary, containing the version, flags, sequence, timestamp, and SSRC. - * - * @var string */ - protected $header; + protected string $header; /** * The buffer containing the voice packet. * * @deprecated - * - * @var Buffer */ - protected $buffer; + protected Buffer $buffer; /** * The version and flags. - * - * @var string|null The version and flags. */ - public $versionPlusFlags; + public ?string $versionPlusFlags; /** * The payload type. - * - * @var string|null The payload type. */ - public $payloadType; + public ?string $payloadType; /** * The encrypted audio. - * - * @var string|null The encrypted audio. */ - public $encryptedAudio; + public ?string $encryptedAudio; /** * The dencrypted audio. - * - * @var string|false|null */ - public $decryptedAudio; + public null|false|string $decryptedAudio; /** * The secret key. - * - * @var string|null The secret key. */ - public $secretKey; + public ?string $secretKey; /** * The raw data - * - * @var string */ - protected $rawData; + protected string $rawData; /** * Current packet header size. May differ depending on the RTP header. - * - * @var int */ - protected $headerSize; + protected int $headerSize; /** * Constructs the voice packet. @@ -124,7 +107,7 @@ public function __construct( $this->decrypt(); } - if (!$log) { + if (! $log) { $this->log = logger(); } } @@ -142,10 +125,6 @@ public function __construct( * @see https://discord.com/developers/docs/topics/voice-connections#transport-encryption-modes-voice-packet-structure * @see https://www.php.net/manual/en/function.unpack.php * @see https://www.php.net/manual/en/function.pack.php For the formats - * - * @param string $message The voice message to unpack. - * - * @return self The unpacked voice packet. */ public function unpack(string $message): self { @@ -184,10 +163,6 @@ public function unpack(string $message): self /** * Decrypts the voice message. - * - * @param string|null $message The message to decrypt. - * - * @return string|false|null */ public function decrypt(?string $message = null): string|false|null { @@ -262,8 +237,6 @@ public function decrypt(?string $message = null): string|false|null * Initilizes the buffer with no encryption. * * @deprecated - * - * @param string $data The Opus data to encode. */ protected function initBufferNoEncryption(string $data): void { @@ -277,9 +250,6 @@ protected function initBufferNoEncryption(string $data): void /** * Initilizes the buffer with encryption. - * - * @param string $data The Opus data to encode. - * @param string $key The encryption key. */ protected function initBufferEncryption(string $data, string $key): void { @@ -297,8 +267,6 @@ protected function initBufferEncryption(string $data, string $key): void /** * Builds the header. - * - * @return Buffer The header. */ protected function buildHeader(): Buffer { @@ -310,6 +278,10 @@ protected function buildHeader(): Buffer ->writeUInt($this->ssrc, HeaderValuesEnum::SSRC_INDEX->value); } + /** + * Sets the header. + * If no message is provided, it will use the raw data of the packet. + */ public function setHeader(?string $message = null): ?string { if (null === $message) { @@ -330,6 +302,9 @@ public function setHeader(?string $message = null): ?string return substr($message, 0, $this->headerSize); } + /** + * Returns the header. + */ public function getHeader(): ?string { return $this?->header ?? null; @@ -337,8 +312,6 @@ public function getHeader(): ?string /** * Returns the sequence. - * - * @return int The packet sequence. */ public function getSequence(): int { @@ -347,8 +320,6 @@ public function getSequence(): int /** * Returns the timestamp. - * - * @return int The packet timestamp. */ public function getTimestamp(): int { @@ -357,8 +328,6 @@ public function getTimestamp(): int /** * Returns the SSRC. - * - * @return int The packet SSRC. */ public function getSSRC(): int { @@ -367,8 +336,6 @@ public function getSSRC(): int /** * Returns the data. - * - * @return string The packet data. */ public function getData(): string { @@ -380,10 +347,6 @@ public function getData(): string /** * Creates a voice packet from data sent from Discord. - * - * @param string $data Data from Discord. - * - * @return self A voice packet. */ public static function make(string $data): self { @@ -397,10 +360,6 @@ public static function make(string $data): self /** * Sets the buffer. - * - * @param Buffer $buffer The buffer to set. - * - * @return $this */ public function setBuffer(Buffer $buffer): self { @@ -415,8 +374,6 @@ public function setBuffer(Buffer $buffer): self /** * Handles to string casting of object. - * - * @return string */ public function __toString(): string { @@ -426,8 +383,6 @@ public function __toString(): string /** * Retrieves the decrypted audio data. * Will return null if the audio data is not decrypted and false on error. - * - * @return string|false|null */ public function getAudioData(): string|false|null { diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php index 751d632b9..7bf56c3db 100644 --- a/src/Discord/Voice/Client/UDP.php +++ b/src/Discord/Voice/Client/UDP.php @@ -23,42 +23,34 @@ final class UDP extends Socket { /** * The Parent Voice WebSocket Client. - * - * @var WS */ protected WS $ws; /** * Silence Frame Remain Count. - * - * @var int Amount of silence frames remaining. */ public int $silenceRemaining = 5; /** * The Opus Silence Frame. - * - * @var string The silence frame. */ public const string SILENCE_FRAME = "\0xF8\0xFF\0xFE"; /** * The stream time of the last packet. - * - * @var int The time we sent the last packet. */ public int $streamTime = 0; /** * Current heartbeat timer. */ - public ?TimerInterface $heartbeat; + public ?TimerInterface $heartbeat = null; /** * Heartbeat interval in milliseconds. * The interval at which the heartbeat is sent. */ - public int $hbInterval; + public ?int $hbInterval = null; /** * Heartbeat sequence number. @@ -68,25 +60,19 @@ final class UDP extends Socket /** * The IP address of the UDP server. - * - * @var string The IP address we are connected to. */ public string $ip; /** * The port of the UDP server. - * - * @var int The port we are connected to. */ public int $port; /** - * The SSRC (Synchronization Source) identifier. + * The SSRC identifier. * This is used to identify the source of the audio stream. - * - * @var int The SSRC we are using for the voice connection. */ - public int $ssrc; + public null|string|int $ssrc; /** * @param \React\EventLoop\LoopInterface $loop @@ -111,10 +97,6 @@ public function __construct($loop, $socket, $buffer = null, ?WS $ws = null) /** * Handles incoming messages from the UDP server. * This is where we handle the audio data received from the server. - * - * @param string $secret The secret key used to decrypt the audio data. - * - * @return UDP */ public function handleMessages(string $secret): self { @@ -135,8 +117,6 @@ public function handleMessages(string $secret): self /** * Handles the sending of the SSRC to the server. * This is necessary for the server to know which SSRC we are using. - * - * @return UDP */ public function handleSsrcSending(): self { @@ -152,8 +132,6 @@ public function handleSsrcSending(): self /** * Handles the heartbeat for the UDP client. * To keep the connection open and responsive. - * - * @return UDP */ public function handleHeartbeat(): self { @@ -189,8 +167,6 @@ function (): void { * To discover which IP and port we should connect to. * * @see https://discord.com/developers/docs/topics/voice-connections#ip-discovery - * - * @return UDP */ public function decodeOnce(): self { @@ -231,9 +207,7 @@ public function decodeOnce(): self } /** - * * Handles errors that occur during UDP communication. - * - * @return UDP + * Handles errors that occur during UDP communication. */ public function handleErrors(): self { @@ -257,8 +231,6 @@ public function insertSilence(): void /** * Sends a buffer to the UDP socket. - * - * @param string $data The data to send to the UDP server. */ public function sendBuffer(string $data): void { @@ -283,8 +255,6 @@ public function sendBuffer(string $data): void /** * Closes the UDP client and cancels the heartbeat timer. - * - * @return void */ public function close(): void { @@ -296,6 +266,9 @@ public function close(): void parent::close(); } + /** + * Refreshes the silence frames. + */ public function refreshSilenceFrames(): void { if (! $this->ws->vc->paused) { diff --git a/src/Discord/Voice/Client/WS.php b/src/Discord/Voice/Client/WS.php index f51999add..40c7cec97 100644 --- a/src/Discord/Voice/Client/WS.php +++ b/src/Discord/Voice/Client/WS.php @@ -27,14 +27,15 @@ */ final class WS { + /** + * The WebSocket instance for the voice connection. + */ protected WebSocket $socket; /** * The Discord voice gateway version. * * @see https://discord.com/developers/docs/topics/voice-connections#voice-gateway-versioning-gateway-versions - * - * @var int Voice Gateway version. */ protected static $version = 8; @@ -47,28 +48,46 @@ final class WS /** * The secret key used for encrypting voice. - * - * @var string|null The secret key. */ - public $secretKey; + public ?string $secretKey; /** * The raw secret key. - * - * @var array|null The raw secret key. */ - public $rawKey; + public ?array $rawKey; - public $ssrc; + /** + * The SSRC identifier for the voice connection client. + */ + public null|string|int $ssrc; + /** + * Indicates whether the login frame has been sent. + */ private bool $sentLoginFrame = false; + /** + * The heartbeat timer for the voice connection. + */ protected TimerInterface $heartbeat; + /** + * The heartbeat interval for the voice connection. + */ protected $hbInterval; + /** + * The heartbeat sequence number. + * + * This is used to track the sequence of heartbeat messages sent to the voice gateway. + */ protected int $hbSequence = 0; + /** + * The WebSocket connection for the voice client. + * + * This is used to send and receive messages over the WebSocket connection. + */ public function __construct( public VoiceClient $vc, protected ?Discord $bot = null, @@ -77,6 +96,10 @@ public function __construct( $this->data ??= $this->vc->data; $this->bot ??= $this->vc->bot; + if (! isset($this->data['endpoint'])) { + throw new \InvalidArgumentException('Endpoint is required for the voice WebSocket connection.'); + } + $f = new Connector($this->bot->loop); /** @var PromiseInterface */ @@ -90,6 +113,15 @@ public function __construct( ); } + /** + * Creates a new instance of the WS class. + * + * @param \Discord\Voice\VoiceClient $vc + * @param null|\Discord\Discord $bot + * @param null|array $data + * + * @return \Discord\Voice\Client\WS + */ public static function make(VoiceClient $vc, ?Discord $bot = null, ?array $data = null): self { return new self($vc, $bot, $data); @@ -97,8 +129,6 @@ public static function make(VoiceClient $vc, ?Discord $bot = null, ?array $data /** * Handles a WebSocket connection. - * - * @param WebSocket $ws The WebSocket instance. */ public function handleConnection(WebSocket $ws): void { @@ -252,8 +282,6 @@ public function handleConnection(WebSocket $ws): void /** * Sends a message to the voice websocket. - * - * @param VoicePayload|array $data The data to send to the voice WebSocket. */ public function send(VoicePayload|array $data): void { @@ -353,6 +381,9 @@ protected function handleDaveMlsInvalidCommitWelcome($data) )); } + /** + * Sends a heartbeat to the voice WebSocket. + */ public function sendHeartbeat(): void { $this->send(VoicePayload::new( @@ -366,6 +397,12 @@ public function sendHeartbeat(): void $this->vc->emit('ws-heartbeat', []); } + /** + * Handles the close event of the WebSocket connection. + * + * @param int $op The opcode of the close event. + * @param string $reason The reason for closing the connection. + */ public function handleClose(int $op, string $reason): void { $this->bot->logger->warning('voice websocket closed', ['op' => $op, 'reason' => $reason]); @@ -390,6 +427,7 @@ public function handleClose(int $op, string $reason): void // Don't reconnect on a critical opcode or if closed by user. if (in_array($op, Op::getCriticalVoiceCloseCodes()) || $this?->vc->userClose) { $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); + $this->vc->close(); $this->vc->emit('close'); return; @@ -403,10 +441,16 @@ public function handleClose(int $op, string $reason): void $this->vc->sentLoginFrame = false; $this->sentLoginFrame = false; - $this->vc->start(); + $this->vc->boot(); }); } + /** + * Handles sending the login frame to the voice WebSocket. + * + * This method sends the initial identification payload to the voice gateway + * to establish the voice connection. + */ public function handleSendingOfLoginFrame(): void { if ($this->sentLoginFrame) { diff --git a/src/Discord/Voice/Processes/Ffmpeg.php b/src/Discord/Voice/Processes/Ffmpeg.php index c60a54778..d29ae6447 100644 --- a/src/Discord/Voice/Processes/Ffmpeg.php +++ b/src/Discord/Voice/Processes/Ffmpeg.php @@ -16,8 +16,8 @@ final class Ffmpeg extends ProcessAbstract { protected static string $exec = '/usr/bin/ffmpeg'; - public function __construct( - ) { + public function __construct() + { if (!$this->checkForFFmpeg()) { throw new FFmpegNotFoundException('FFmpeg binary not found.'); } @@ -66,7 +66,7 @@ public static function encode( '-map_metadata', '-1', '-f', 'opus', '-c:a', 'libopus', - '-ar', '48000', + '-ar', parent::DEFAULT_KHZ, '-af', "volume={$volume}dB", '-ac', '2', '-b:a', $bitrate, @@ -133,12 +133,12 @@ public static function decode( '-loglevel', 'error', // Set log level to warning to reduce output noise '-channel_layout', 'stereo', '-ac', $channels, - '-ar', '48000', + '-ar', parent::DEFAULT_KHZ, '-f', 's16le', '-i', 'pipe:0', '-acodec', 'libopus', '-f', 'ogg', - '-ar', '48000', + '-ar', parent::DEFAULT_KHZ, '-ac', $channels, '-b:a', $bitrate, $filename diff --git a/src/Discord/Voice/Processes/ProcessAbstract.php b/src/Discord/Voice/Processes/ProcessAbstract.php index 8cae5ffeb..80618b155 100644 --- a/src/Discord/Voice/Processes/ProcessAbstract.php +++ b/src/Discord/Voice/Processes/ProcessAbstract.php @@ -15,13 +15,16 @@ */ abstract class ProcessAbstract { - abstract public static function encode( - ?string $filename = null, - int|float $volume = 0, - int $bitrate = 128000, - ?array $preArgs = null, - ): Process; + /** + * Encodes audio to a specific format. + */ + abstract public static function encode(?string $filename = null, int|float $volume = 0, int $bitrate = 128000, ?array $preArgs = null): Process; + + public const int DEFAULT_KHZ = 48000; + /** + * Decodes audio from a specific format. + */ abstract public static function decode( ?string $filename = null, int|float $volume = 0, @@ -31,6 +34,9 @@ abstract public static function decode( ?array $preArgs = null, ): Process; + /** + * Checks if the specified executable is available on the system. + */ public static function checkForExecutable(string $exec): ?string { $systemOs = substr(PHP_OS, 0, 3); diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index a28381346..dae88b580 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -87,14 +87,14 @@ class VoiceClient extends EventEmitter * * @var int|null How often we send a heartbeat packet. */ - public ?int $heartbeatInterval; + public ?int $heartbeatInterval = null; /** * The Voice WebSocket heartbeat timer. * * @var TimerInterface|null The heartbeat periodic timer. */ - public ?TimerInterface $heartbeat; + public ?TimerInterface $heartbeat = null; /** * The SSRC value. @@ -287,19 +287,26 @@ class VoiceClient extends EventEmitter public function __construct( public Discord $bot, public Channel $channel, - public array $data, + public array $data = [], public bool $deaf = false, public bool $mute = false, protected ?Deferred $deferred = null, protected ?VoiceManager &$manager = null, + protected bool $shouldBoot = true ) { $this->deaf = $this->data['deaf'] ?? false; $this->mute = $this->data['mute'] ?? false; - $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - $this->speakingStatus = Collection::for(VoiceSpeaking::class, 'ssrc'); - $this->dnsConfig = $data['dnsConfig']; - $this->boot(); + $this->data = [ + 'user_id' => $this->bot->id, + 'deaf' => $this->deaf, + 'mute' => $this->mute, + 'session' => $this->data['session'] ?? null, + ]; + + if ($this->shouldBoot) { + $this->boot(); + } } /** @@ -929,19 +936,19 @@ public function close(): void $this->ready = false; // Close processes for audio encoding - if (count($this->voiceDecoders) > 0) { + if (count($this?->voiceDecoders ?? []) > 0) { foreach ($this->voiceDecoders as $decoder) { $decoder->close(); } } - if (count($this->receiveStreams) > 0) { + if (count($this?->receiveStreams ?? []) > 0) { foreach ($this->receiveStreams as $stream) { $stream->close(); } } - if (count($this->speakingStatus) > 0) { + if (count($this?->speakingStatus ?? []) > 0) { foreach ($this->speakingStatus as $ss) { $this->removeDecoder($ss); } @@ -1238,16 +1245,6 @@ public function getDbVolume(): float|int }; } - /** - * Returns the connected channel. - * - * @return Channel The connected channel. - */ - public function getChannel(): Channel - { - return $this->channel; - } - /** * Creates a new voice client instance statically * @@ -1256,21 +1253,15 @@ public function getChannel(): Channel * @param array $data * @param bool $deaf * @param bool $mute - * @param mixed $deferred - * @param mixed $manager - * @param array $ + * @param null|Deferred $deferred + * @param null|VoiceManager $manager + * @param bool $shouldBoot Whether the client should boot immediately. + * * @return \Discord\Voice\VoiceClient */ - public static function make( - Discord $bot, - Channel $channel, - array $data, - bool $deaf = false, - bool $mute = false, - ?Deferred $deferred = null, - ?VoiceManager &$manager = null, - ): self { - return new static($bot, $channel, $data, $deaf, $mute, $deferred, $manager); + public static function make(): self + { + return new static(...func_get_args()); } /** @@ -1282,6 +1273,10 @@ public function boot(): bool { return $this->once('ready', function () { $this->bot->getLogger()->info('voice client is ready'); + if (isset($this->manager->clients[$this->channel->guild_id])) { + $this->disconnect(); + } + $this->manager->clients[$this->channel->guild_id] = $this; $this->setBitrate($this->channel->bitrate); @@ -1334,4 +1329,18 @@ public function stopRecording(): void $this->receiveStreams = []; $this->speakingStatus = Collection::for(VoiceSpeaking::class, 'ssrc'); } + + public function setData(array $data): self + { + $this->data = $data; + + if (isset($this->data['token'], $this->data['endpoint'], $this->data['session'], $this->data['dnsConfig'])) { + $this->endpoint = str_replace([':80', ':443'], '', $this->data['endpoint']); + $this->dnsConfig = $this->data['dnsConfig']; + $this->data['user_id'] ??= $this->bot->id; + $this->boot(); + } + + return $this; + } } diff --git a/src/Discord/Voice/VoiceManager.php b/src/Discord/Voice/VoiceManager.php index ed9af821f..537ad6362 100644 --- a/src/Discord/Voice/VoiceManager.php +++ b/src/Discord/Voice/VoiceManager.php @@ -11,11 +11,14 @@ use Discord\Exceptions\Voice\ClientMustAllowVoiceException; use Discord\Exceptions\Voice\EnterChannelDeniedException; use Discord\Parts\Channel\Channel; +use Discord\Parts\WebSockets\VoiceServerUpdate; +use Discord\Parts\WebSockets\VoiceStateUpdate; use Discord\WebSockets\Event; use Discord\WebSockets\Op; use Discord\WebSockets\VoicePayload; use Evenement\EventEmitterTrait; use React\Promise\Deferred; +use React\Promise\PromiseInterface; /** * Manages voice clients for the Discord bot. @@ -46,14 +49,16 @@ public function __construct( * @param \Discord\Discord $discord * @param bool $mute * @param bool $deaf + * + * @throws \Discord\Exceptions\Voice\ChannelMustAllowVoiceException + * @throws \Discord\Exceptions\Voice\EnterChannelDeniedException + * @throws \Discord\Exceptions\Voice\CantJoinMoreThanOneChannelException + * @throws \Discord\Exceptions\Voice\CantSpeakInChannelException + * * @return \React\Promise\PromiseInterface */ - public function createClientAndJoinChannel( - Channel $channel, - Discord $discord, - bool $mute = false, - bool $deaf = true, - ) { + public function joinChannel(Channel $channel, Discord $discord, bool $mute = false, bool $deaf = true): PromiseInterface + { $deferred = new Deferred(); try { @@ -69,6 +74,7 @@ public function createClientAndJoinChannel( throw new CantSpeakInChannelException(); } + // TODO: Make this an option for the user instead of being forced if (isset($this->clients[$channel->guild_id])) { throw new CantJoinMoreThanOneChannelException(); } @@ -77,16 +83,21 @@ public function createClientAndJoinChannel( return $deferred->promise(); } - $this->clients[$channel->guild_id] = ['data' => []]; - $this->clients[$channel->guild_id]['data'] = [ - 'user_id' => $this->bot->id, - 'deaf' => $deaf, - 'mute' => $mute, - ]; - - $discord->once(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); + // The same as new VoiceClient(...) + $this->clients[$channel->guild_id] = VoiceClient::make( + $this->bot, + $channel, + ['dnsConfig' => $discord->options['dnsConfig']], + $deaf, + $mute, + $deferred, + $this, + false + ); + + $discord->on(Event::VOICE_STATE_UPDATE, fn ($state) => $this->stateUpdate($state, $channel)); // Creates Voice Client and waits for the voice server update. - $discord->once(Event::VOICE_SERVER_UPDATE, fn ($state, Discord $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); + $discord->on(Event::VOICE_SERVER_UPDATE, fn ($state, Discord $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); $discord->send(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, @@ -101,45 +112,79 @@ public function createClientAndJoinChannel( return $deferred->promise(); } - public function getClient(string|int $guildId): ?VoiceClient + /** + * Retrieves the voice client for a specific guild. + * + * @param string|int $guildId + * + * @return \Discord\Voice\VoiceClient|null + */ + public function getClient(string|int|Channel $guildChannelOrId): ?VoiceClient { - if (! isset($this->clients[$guildId])) { + if ($guildChannelOrId instanceof Channel) { + $guildChannelOrId = $guildChannelOrId->guild_id; + } + + if (! isset($this->clients[$guildChannelOrId])) { return null; } - return $this->clients[$guildId]; + return $this->clients[$guildChannelOrId]; } - protected function stateUpdate($state, Channel $channel): void + /** + * Handles the voice state update event to update session information for the voice client. + * + * @param \Discord\Parts\WebSockets\VoiceStateUpdate $state + * @param \Discord\Parts\Channel\Channel $channel + * + * @return void + */ + protected function stateUpdate(VoiceStateUpdate $state, Channel $channel): void { if ($state->guild_id != $channel->guild_id) { return; // This voice state update isn't for our guild. } - $this->clients[$channel->guild_id]['data']['session'] = $state->session_id; + $this->getClient($channel) + ->setData(['session' => $state->session_id, 'deaf' => $state->deaf, 'mute' => $state->mute]); + $this->bot->getLogger()->info('received session id for voice session', ['guild' => $channel->guild_id, 'session_id' => $state->session_id]); } - protected function serverUpdate($state, Channel $channel, Discord $discord, Deferred $deferred): void + /** + * Handles the voice server update event to create a new voice client with the provided state. + * + * @param \Discord\Parts\WebSockets\VoiceServerUpdate $state + * @param \Discord\Parts\Channel\Channel $channel + * @param \Discord\Discord $discord + * @param \React\Promise\Deferred $deferred + * + * @return void + */ + protected function serverUpdate(VoiceServerUpdate $state, Channel $channel, Discord $discord, Deferred $deferred): void { if ($state->guild_id !== $channel->guild_id) { return; // This voice server update isn't for our guild. } - $data = $this->clients[$channel->guild_id]['data']; - unset($this->clients[$channel->guild_id]['data']); - - $data['token'] = $state->token; - $data['endpoint'] = $state->endpoint; - $data['dnsConfig'] = $discord->options['dnsConfig']; - $this->bot->getLogger()->info('received token and endpoint for voice session', [ 'guild' => $channel->guild_id, 'token' => $state->token, 'endpoint' => $state->endpoint ]); - VoiceClient::make($discord, $channel, $data, deferred: $deferred, manager: $this); + $client = $this->getClient($channel); + + $client->setData(array_merge( + $client->data, + [ + 'token' => $state->token, + 'endpoint' => $state->endpoint, + 'session' => $client->data['session'] ?? null, + ], + ['dnsConfig' => $discord->options['dnsConfig']]) + ); } }