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 diff --git a/composer.json b/composer.json index a1a9add24..d5f394efc 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": "*", @@ -30,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": "*", @@ -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/Discord.php b/src/Discord/Discord.php index 6becde039..e6ede7b3a 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; @@ -34,13 +35,15 @@ use Discord\Repository\GuildRepository; use Discord\Repository\PrivateChannelRepository; 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\Payload; use Discord\WebSockets\Handlers; use Discord\WebSockets\Intents; use Discord\WebSockets\Op; +use Discord\WebSockets\Payload; use Evenement\EventEmitterTrait; use Monolog\Formatter\LineFormatter; use Monolog\Handler\StreamHandler; @@ -81,7 +84,6 @@ * @property PrivateChannelRepository $private_channels * @property SoundRepository $sounds * @property UserRepository $users - */ class Discord { @@ -108,13 +110,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 +187,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 +335,13 @@ class Discord */ private $application_commands; + /** + * The voice handler, of clients and packets. + * + * @var VoiceManager + */ + public VoiceManager $voice; + /** * The transport compression setting. * @@ -361,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. * @@ -372,7 +374,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); @@ -382,7 +384,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.'); } @@ -414,19 +417,28 @@ 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; + } } /** - * Handles `VOICE_SERVER_UPDATE` packets. + * Resolves the called methods through the already created Discord instance. * - * @param Payload $data Packet data. + * @param array $options Array of options. + * + * @return mixed */ - protected function handleVoiceServerUpdate(Payload $data): void + public static function __callStatic($method, $args) { - if (isset($this->voiceClients[$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); + if (method_exists(self::class, $method)) { + return self::$instance->$method(...$args); } + + throw new \BadMethodCallException("Method {$method} does not exist in " . __CLASS__); } /** @@ -604,15 +616,19 @@ 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); } } /** * 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 @@ -782,6 +798,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 @@ -806,7 +828,6 @@ protected function handleDispatch(object $data): void $this->{$handlers[$data->t]}(Payload::new($data->op, $data->d, $data->s, $data->t)); } - return; } @@ -852,11 +873,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']); } /** @@ -1157,7 +1180,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 @@ -1184,6 +1207,9 @@ protected function ready() } $this->emittedInit = true; + $this->voice = new VoiceManager($this); + $this->logger->info('voice class initialized'); + $this->logger->info('client is ready'); $this->emit('init', [$this]); @@ -1245,12 +1271,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->getClient($guildId) * * @return VoiceClient|null */ - public function getVoiceClient(string $guild_id): ?VoiceClient + public function getVoiceClient(string|int $guildId): ?VoiceClient { - return $this->voiceClients[$guild_id] ?? null; + return $this->voice->getClient($guildId); } /** @@ -1266,95 +1293,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->joinChannel($channel, $this, $mute, $deaf); } /** @@ -1641,7 +1584,7 @@ public function getLogger(): LoggerInterface */ public function getHttp(): Http { - return $this->http; + return $this->getHttpClient(); } /** @@ -1676,6 +1619,7 @@ public function __get(string $name) } if (null === $this->client) { + // TODO: Throw an exception here? return; } @@ -1691,6 +1635,7 @@ public function __get(string $name) public function __set(string $name, $value): void { if (null === $this->client) { + // TODO: Throw an exception here? return; } @@ -1716,6 +1661,8 @@ public function getChannel($channel_id): ?Channel return $channel; } + // TODO: Throw an exception here? + return null; } @@ -1765,6 +1712,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; } @@ -1791,4 +1739,14 @@ public function __debugInfo(): array return $config; } + + public function getWs(): ?WebSocket + { + return $this->ws; + } + + public static function getInstance(): ?self + { + return self::$instance; + } } 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.'); + } +} diff --git a/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php b/src/Discord/Exceptions/Voice/AudioAlreadyPlayingException.php new file mode 100644 index 000000000..edbbc0cf7 --- /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 + */ +final class AudioAlreadyPlayingException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'Audio is already playing.'); + } +} 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 new file mode 100644 index 000000000..dbee4dbcb --- /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 + */ +final class ClientNotReadyException extends \RuntimeException +{ + public function __construct(?string $message = null) + { + parent::__construct($message ?? 'Voice Client is not ready.'); + } +} 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/Factory/SocketFactory.php b/src/Discord/Factory/SocketFactory.php new file mode 100644 index 000000000..7e8c154f0 --- /dev/null +++ b/src/Discord/Factory/SocketFactory.php @@ -0,0 +1,42 @@ +createCached($ws->data['dnsConfig'], $loop); + } + + parent::__construct($loop, $resolver); + + if ($ws !== null) { + $this->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/Helpers/Buffer.php b/src/Discord/Helpers/Buffer.php index f78990603..e452bf6ff 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,9 +52,9 @@ class Buffer extends EventEmitter implements WritableStreamInterface * * @var LoopInterface */ - private $loop; + protected $loop; - public function __construct(LoopInterface $loop = null) + public function __construct(?LoopInterface $loop = null) { $this->loop = $loop; } @@ -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); @@ -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/AbstractBuffer.php b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php new file mode 100644 index 000000000..cf5e797b3 --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/AbstractBuffer.php @@ -0,0 +1,28 @@ + + * + * 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..f6826645c --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/Buffer.php @@ -0,0 +1,304 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Helpers\ByteBuffer; + +use Discord\Helpers\FormatPackEnum; + +/** + * Helper class for handling binary data. + * + * @author alexandre433 + * + * @throws \InvalidArgumentException If invalid arguments are provided or buffer overflows. + */ +class Buffer extends AbstractBuffer implements \ArrayAccess +{ + use BufferArrayAccessTrait; + + protected \SplFixedArray $buffer; + + public function __construct($argument) + { + 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 + { + return implode('', iterator_to_array($this->buffer, false)); + } + + public static function make($argument): static + { + return new static($argument); + } + + protected function initializeStructs($length, string $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(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; + } + + /** + * 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, ?int $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, ?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; + } + + /** + * 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) { + $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, ?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; + } + + /** + * 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) { + $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, ?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; + } + + /** + * 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); + } + + 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/Voice/Buffer.php b/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php similarity index 62% rename from src/Discord/Voice/Buffer.php rename to src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php index 226156fb0..9a5d7d9fe 100644 --- a/src/Discord/Voice/Buffer.php +++ b/src/Discord/Helpers/ByteBuffer/BufferArrayAccessTrait.php @@ -11,17 +11,14 @@ * with this source code in the LICENSE.md file. */ -namespace Discord\Voice; +namespace Discord\Helpers\ByteBuffer; -use ArrayAccess; -use TrafficCophp\ByteBuffer\Buffer as BaseBuffer; +use Discord\Helpers\FormatPackEnum; /** - * A Byte Buffer similar to Buffer in NodeJS. - * - * @since 3.2.0 + * @author Valithor Obsidion */ -class Buffer extends BaseBuffer implements ArrayAccess +trait BufferArrayAccessTrait { /** * Writes a 32-bit unsigned integer with big endian. @@ -29,9 +26,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 +37,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 +48,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 +73,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 +94,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 +108,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 +120,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 +139,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()); } /** @@ -139,7 +169,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. * @@ -148,11 +178,11 @@ public function writeRawString(string $value, int $offset): void #[\ReturnTypeWillChange] public function offsetGet($key) { - return $this->buffer[$key]; + return $this->buffer[$key] ?? null; } /** - * 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. * @@ -164,7 +194,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. @@ -175,7 +205,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. */ diff --git a/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php b/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php new file mode 100644 index 000000000..a6d1e064a --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/ReadableBuffer.php @@ -0,0 +1,33 @@ + + * + * 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..e96e4a798 --- /dev/null +++ b/src/Discord/Helpers/ByteBuffer/WriteableBuffer.php @@ -0,0 +1,68 @@ + + * + * 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 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; + + /** + * Write an int16 to the buffer in big-endian format + * + * @param mixed $value + * @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; + + /** + * Write an int16 to the buffer in little-endian format + * + * @param mixed $value + * @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; + + /** + * Write an int32 to the buffer in big-endian format + * + * @param mixed $value + * @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; + + /** + * Write an int32 to the buffer in little-endian format + * + * @param mixed $value + * @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/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/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; + } } 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/Parts/EventData/VoiceSpeaking.php b/src/Discord/Parts/EventData/VoiceSpeaking.php new file mode 100644 index 000000000..334b1e862 --- /dev/null +++ b/src/Discord/Parts/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\Parts\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 + ]; +} 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..e86e684ae --- /dev/null +++ b/src/Discord/Voice/Client/HeaderValuesEnum.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\Voice\Client; + +use Discord\Exceptions\LibSodiumNotFoundException; +use Discord\Helpers\ByteBuffer\Buffer; +use Discord\Helpers\FormatPackEnum; +use Monolog\Logger; + +use function Discord\logger; + +/** + * A voice packet received from Discord. + * + * Huge thanks to Austin and Michael from JDA for the constants and audio + * packets. Check out their repo: + * https://github.com/DV8FromTheWorld/JDA + * + * @since 10.19.0 + */ +final class Packet +{ + /** + * The audio header, in binary, containing the version, flags, sequence, timestamp, and SSRC. + */ + protected string $header; + + /** + * The buffer containing the voice packet. + * + * @deprecated + */ + protected Buffer $buffer; + + /** + * The version and flags. + */ + public ?string $versionPlusFlags; + + /** + * The payload type. + */ + public ?string $payloadType; + + /** + * The encrypted audio. + */ + public ?string $encryptedAudio; + + /** + * The dencrypted audio. + */ + public null|false|string $decryptedAudio; + + /** + * The secret key. + */ + public ?string $secretKey; + + /** + * The raw data + */ + protected string $rawData; + + /** + * Current packet header size. May differ depending on the RTP header. + */ + protected int $headerSize; + + /** + * Constructs the voice packet. + * + * @param string $data The Opus data to encode. + * @param int $ssrc The client SSRC value. + * @param int $seq The packet sequence. + * @param int $timestamp The packet timestamp. + * @param bool $encryption Whether the packet should be encrypted. + * @param string|null $key The encryption key. + */ + 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 + ) { + if (! function_exists('sodium_crypto_secretbox')) { + throw new LibSodiumNotFoundException('libsodium-php could not be found.'); + } + + $this->unpack($data); + + if ($decrypt) { + $this->decrypt(); + } + + if (! $log) { + $this->log = logger(); + } + } + + /** + * 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]); + return $this; + } + + $byteData = substr( + $message, + 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); + + 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. + */ + public function decrypt(?string $message = null): string|false|null + { + if (! $message) { + $message = $this?->rawData ?? null; + } + + if (empty($message)) { + // throw error here + return null; + } + + // total message length + $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 - 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 - HeaderValuesEnum::AUTH_TAG_LENGTH->value - HeaderValuesEnum::TIMESTAMP_OR_NONCE_INDEX->value; + $cipherText = substr($message, $this->headerSize, $encryptedLength); + $authTag = substr($message, $this->headerSize + $encryptedLength, HeaderValuesEnum::AUTH_TAG_LENGTH->value); + + // 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 + */ + protected function initBufferNoEncryption(string $data): void + { + $data = (string) $data; + $header = $this->buildHeader(); + + $this->buffer = Buffer::make(strlen((string) $header) + strlen($data)) + ->write((string) $header, 0) + ->write($data, 12); + } + + /** + * Initilizes the buffer with encryption. + */ + protected function initBufferEncryption(string $data, string $key): void + { + $data = (string) $data; + $header = $this->buildHeader(); + $nonce = new Buffer(24); + $nonce->write((string) $header, 0); + + $data = \sodium_crypto_secretbox($data, (string) $nonce, $key); + + $this->buffer = new Buffer(strlen((string) $header) + strlen($data)); + $this->buffer->write((string) $header, 0); + $this->buffer->write($data, 12); + } + + /** + * Builds the header. + */ + protected function buildHeader(): Buffer + { + $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); + } + + /** + * 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) { + $message = $this?->rawData; + } + + if (empty($message)) { + // throw error here + return null; + } + + $this->headerSize = HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value; + $firstByte = ord($message[0]); + if (($firstByte >> 4) & 0x01) { + $this->headerSize += 4; + } + + return substr($message, 0, $this->headerSize); + } + + /** + * Returns the header. + */ + public function getHeader(): ?string + { + return $this?->header ?? null; + } + + /** + * Returns the sequence. + */ + public function getSequence(): int + { + return $this->seq; + } + + /** + * Returns the timestamp. + */ + public function getTimestamp(): int + { + return $this->timestamp; + } + + /** + * Returns the SSRC. + */ + public function getSSRC(): int + { + return $this->ssrc; + } + + /** + * Returns the data. + */ + public function getData(): string + { + return $this->buffer->read( + HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value, + strlen((string) $this->buffer) - HeaderValuesEnum::RTP_HEADER_OR_NONCE_LENGTH->value + ); + } + + /** + * Creates a voice packet from data sent from Discord. + */ + public static function make(string $data): self + { + $n = new self('', 0, 0, 0); + $buff = new Buffer($data); + $n->setBuffer($buff); + unset($buff); + + return $n; + } + + /** + * Sets the buffer. + */ + public function setBuffer(Buffer $buffer): self + { + $this->buffer = $buffer; + + $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; + } + + /** + * Handles to string casting of object. + */ + public function __toString(): string + { + return (string) $this?->buffer ?? $this->decryptedAudio ?? ''; + } + + /** + * Retrieves the decrypted audio data. + * Will return null if the audio data is not decrypted and false on error. + */ + public function getAudioData(): string|false|null + { + return $this?->decryptedAudio; + } +} diff --git a/src/Discord/Voice/Client/UDP.php b/src/Discord/Voice/Client/UDP.php new file mode 100644 index 000000000..7bf56c3db --- /dev/null +++ b/src/Discord/Voice/Client/UDP.php @@ -0,0 +1,281 @@ +ws = $ws; + + if (null === $this->hbInterval) { + // Set the heartbeat interval to the default value if not set. + $this->hbInterval = $this->ws->vc->heartbeatInterval; + } + } + } + + /** + * Handles incoming messages from the UDP server. + * This is where we handle the audio data received from the server. + */ + public function handleMessages(string $secret): self + { + return $this->on('message', function (string $message) use ($secret) { + + if (strlen($message) <= 8) { + 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. + */ + 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->send($buffer->__toString())); + + return $this; + } + + /** + * Handles the heartbeat for the UDP client. + * To keep the connection open and responsive. + */ + public function handleHeartbeat(): self + { + 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 { + $buffer = new Buffer(9); + $buffer[0] = 0xC9; + $buffer->writeUInt64LE($this->hbSequence, 1); + ++$this->hbSequence; + + $this->send($buffer->__toString()); + $this->ws->vc->emit('udp-heartbeat', []); + + logger()->debug('sent UDP heartbeat'); + } + ); + + return $this; + } + + /** + * 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 + */ + 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, + ], + ], + ]); + }); + } + + /** + * Handles errors that occur during UDP communication. + */ + 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]); + }); + } + + /** + * 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. + */ + 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]); + } + + /** + * Closes the UDP client and cancels the heartbeat timer. + */ + public function close(): void + { + if ($this->heartbeat) { + loop()->cancelTimer($this->heartbeat); + $this->heartbeat = null; + } + + parent::close(); + } + + /** + * Refreshes the silence frames. + */ + 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/User.php b/src/Discord/Voice/Client/User.php new file mode 100644 index 000000000..2fec6ed7c --- /dev/null +++ b/src/Discord/Voice/Client/User.php @@ -0,0 +1,27 @@ +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 */ + $f("wss://" . $this->data['endpoint'] . "?v=" . self::$version) + ->then( + fn (WebSocket $ws) => $this->handleConnection($ws), + fn (\Throwable $e) => $this->bot->logger->error( + 'Failed to connect to voice gateway: {error}', + ['error' => $e->getMessage()] + ) && $this->vc->emit('error', arguments: [$e]) + ); + } + + /** + * 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); + } + + /** + * Handles a WebSocket connection. + */ + public function handleConnection(WebSocket $ws): void + { + $this->bot->logger->debug('connected to voice websocket'); + + $udpfac = new SocketFactory($this->bot->loop, ws: $this); + + $this->socket = $this->vc->ws = $ws; + + $ws->on('message', function (Message $message) use ($udpfac): void { + $data = json_decode($message->getPayload()); + $this->vc->emit('ws-message', [$message, $this->vc]); + + 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->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->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)]); + + if (! $this->vc->reconnecting) { + $this->vc->emit('ready', [$this->vc]); + } 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->udp->handleMessages($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)]); + $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: + $this->hbInterval = $this->vc->heartbeatInterval = $data->d->heartbeat_interval; + $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)]); + # "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)]); + 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)]); + 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->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}:" . $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]); + }); + 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->vc->emit('ws-error', [$e]); + }); + + $ws->on('close', [$this, 'handleClose']); + + $this->handleSendingOfLoginFrame(); + } + + /** + * Sends a message to the voice websocket. + */ + public function send(VoicePayload|array $data): void + { + $json = json_encode($data); + $this->socket->send($json); + } + + 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], + )); + } + + protected function handleDaveExecuteTransition($data): void + { + $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): void + { + $this->bot->logger->debug('DAVE Transition Ready', ['data' => $data]); + // Handle transition ready state + } + + protected function handleDavePrepareEpoch($data): void + { + $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): void + { + $this->bot->logger->debug('DAVE MLS External Sender', ['data' => $data]); + // Handle external sender public key and credential + } + + protected function handleDaveMlsKeyPackage($data): void + { + $this->bot->logger->debug('DAVE MLS Key Package', ['data' => $data]); + // Handle MLS key package + } + + protected function handleDaveMlsProposals($data): void + { + $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): void + { + $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(), + ], + )); + } + + /** + * Sends a heartbeat to the voice WebSocket. + */ + public function sendHeartbeat(): void + { + $this->send(VoicePayload::new( + Op::VOICE_HEARTBEAT, + [ + 't' => (int) microtime(true), + 'seq_ack' => ++$this->hbSequence, + ] + )); + $this->bot->logger->debug('sending heartbeat'); + $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]); + $this->vc->emit('ws-close', [$op, $reason, $this]); + + $this->vc->clientsConnected = []; + + // Cancel heartbeat timers + if (null !== $this->vc->heartbeat) { + $this->bot->loop->cancelTimer($this->vc->heartbeat); + $this->vc->heartbeat = null; + } + + // Close UDP socket. + if (isset($this->vc->udp)) { + $this->bot->logger->warning('closing UDP client'); + $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?->vc->userClose) { + $this->bot->logger->warning('received critical opcode - not reconnecting', ['op' => $op, 'reason' => $reason]); + $this->vc->close(); + $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->vc->reconnecting = true; + $this->vc->sentLoginFrame = false; + $this->sentLoginFrame = false; + + $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) { + 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/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; diff --git a/src/Discord/Voice/Processes/DCA.php b/src/Discord/Voice/Processes/DCA.php new file mode 100644 index 000000000..3cbd1827e --- /dev/null +++ b/src/Discord/Voice/Processes/DCA.php @@ -0,0 +1,91 @@ +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', parent::DEFAULT_KHZ, + '-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'], + ] + ); + } + + /** + * 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, + int $bitrate = 128000, + int $channels = 2, + ?int $frameSize = null, + ?array $preArgs = null, + ): Process { + if (null === $frameSize) { + $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 = [ + '-loglevel', 'error', // Set log level to warning to reduce output noise + '-channel_layout', 'stereo', + '-ac', $channels, + '-ar', parent::DEFAULT_KHZ, + '-f', 's16le', + '-i', 'pipe:0', + '-acodec', 'libopus', + '-f', 'ogg', + '-ar', parent::DEFAULT_KHZ, + '-ac', $channels, + '-b:a', $bitrate, + $filename + ]; + + if (null !== $preArgs) { + $flags = array_merge($preArgs, $flags); + } + + $flags = implode(' ', $flags); + + return new Process(self::$exec . " {$flags}"); + } +} diff --git a/src/Discord/Voice/Processes/OpusFfi.php b/src/Discord/Voice/Processes/OpusFfi.php new file mode 100644 index 000000000..354fc2b6c --- /dev/null +++ b/src/Discord/Voice/Processes/OpusFfi.php @@ -0,0 +1,86 @@ +new("const unsigned char[$dataLength]", false); + FFI::memcpy($dataBuffer, $data, $dataLength); + + $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($sampleRate, $channels, FFI::addr($error)); + + // Prepare input data (Opus-encoded) + + if ($dataLength < 0) { + $ffi->opus_decoder_destroy($decoder); + return ''; + } + + // Prepare output buffer for PCM samples + $pcm = $ffi->new("opus_int16[" . $frameSize * $channels * 2 . "]", false); + + // Decode + $ret = $ffi->opus_decode($decoder, $dataBuffer, $dataLength, $pcm, $frameSize, 0); + + if ($ret < 0) { + $ffi->opus_decoder_destroy($decoder); + // TODO: Handle decoding error + 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/Processes/ProcessAbstract.php b/src/Discord/Voice/Processes/ProcessAbstract.php new file mode 100644 index 000000000..80618b155 --- /dev/null +++ b/src/Discord/Voice/Processes/ProcessAbstract.php @@ -0,0 +1,52 @@ + + * + * This file is subject to the MIT license that is bundled + * with this source code in the LICENSE.md file. + */ + +namespace Discord\Voice; + +/** + * Handles recieving audio from Discord. + * + * @since 10.19.0 The class was renamed to ReceiveStream. + */ +class ReceiveStream extends RecieveStream +{ +} diff --git a/src/Discord/Voice/RecieveStream.php b/src/Discord/Voice/RecieveStream.php index 6409105ad..be2f6cd60 100644 --- a/src/Discord/Voice/RecieveStream.php +++ b/src/Discord/Voice/RecieveStream.php @@ -20,6 +20,7 @@ /** * Handles recieving audio from Discord. * + * @deprecated The class was renamed, kept for backwards compatibility. * @since 3.2.0 */ class RecieveStream extends EventEmitter implements DuplexStreamInterface @@ -123,7 +124,7 @@ public function writeOpus(string $opus): void */ public function isReadable() { - return $this->isPaused; + return !$this->isPaused && !$this->isClosed; } /** @@ -131,7 +132,7 @@ public function isReadable() */ public function isWritable() { - return $this->isPaused; + return $this->isReadable(); } /** @@ -140,6 +141,7 @@ public function isWritable() public function write($data) { $this->writePCM($data); + $this->writeOpus($data); } /** @@ -216,6 +218,7 @@ public function resume() public function pipe(WritableStreamInterface $dest, array $options = []) { $this->pipePCM($dest, $options); + $this->pipeOpus($dest, $options); } /** diff --git a/src/Discord/Voice/VoiceClient.php b/src/Discord/Voice/VoiceClient.php index 7f53f5a39..dae88b580 100644 --- a/src/Discord/Voice/VoiceClient.php +++ b/src/Discord/Voice/VoiceClient.php @@ -13,275 +13,181 @@ namespace Discord\Voice; -use Discord\Exceptions\FFmpegNotFoundException; +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; use Discord\Helpers\Buffer as RealBuffer; use Discord\Helpers\Collection; +use Discord\Helpers\ExCollectionInterface; use Discord\Parts\Channel\Channel; -use Discord\WebSockets\Payload; +use Discord\Parts\EventData\VoiceSpeaking; +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; +use Discord\Voice\Processes\Ffmpeg; +use Discord\Voice\Processes\OpusFfi; use Discord\WebSockets\Op; +use Discord\WebSockets\Payload; +use Discord\WebSockets\VoicePayload; 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 Psr\Log\LoggerInterface; use React\ChildProcess\Process; +use React\Datagram\Socket; +use React\Dns\Config\Config; +use React\EventLoop\TimerInterface; use React\Promise\Deferred; use React\Promise\PromiseInterface; use React\Stream\ReadableResourceStream as Stream; -use React\EventLoop\TimerInterface; -use React\Stream\ReadableResourceStream; use React\Stream\ReadableStreamInterface; +use function Discord\logger; +use function Discord\loop; + /** * The Discord voice client. * - * @since 3.2.0 + * @since 10.19.0 */ class VoiceClient extends EventEmitter { - /** - * The DCA version the client is using. - * - * @var string The DCA version. - */ - public const DCA_VERSION = 'DCA1'; - - /** - * The Opus Silence Frame. - * - * @var string The silence frame. - */ - public const SILENCE_FRAME = "\xF8\xFF\xFE"; - /** * Is the voice client ready? * * @var bool Whether the voice client is ready. */ - protected $ready = false; - - /** - * The DCA binary name that we will use. - * - * @var string The DCA binary name that will be run. - */ - protected $dca; - - /** - * The FFmpeg binary location. - * - * @var string - */ - protected $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 $mainWebsocket; + public bool $ready = false; /** * The voice WebSocket instance. * - * @var WebSocket The voice WebSocket client. - */ - protected $voiceWebsocket; - - /** - * The UDP client. - * - * @var Socket The voiceUDP client. + * @var WebSocket|null The voice WebSocket client. */ - public $client; + public ?WebSocket $ws; /** - * The Channel that we are connecting to. + * The UDP client instance. * - * @var Channel The channel that we are going to connect to. + * @var null|Socket|\Discord\Voice\Client\UDP */ - protected $channel; - - /** - * Data from the main WebSocket. - * - * @var array Information required for the voice WebSocket. - */ - protected $data; + public ?UDP $udp; /** * 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 $endpoint; - - /** - * The port the UDP client will use. - * - * @var int The port that the UDP client will connect to. - */ - protected $udpPort; + public ?string $endpoint; /** * The UDP heartbeat interval. * - * @var int How often we send a heartbeat packet. + * @var int|null How often we send a heartbeat packet. */ - protected $heartbeat_interval; + public ?int $heartbeatInterval = null; /** * 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. - */ - protected $udpHeartbeat; - - /** - * The UDP heartbeat sequence. - * - * @var int The heartbeat sequence. - */ - protected $heartbeatSeq = 0; + public ?TimerInterface $heartbeat = null; /** * The SSRC value. * - * @var int The SSRC value used for RTP. + * @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. */ - protected $seq = 0; + public ?int $seq = 0; /** * The timestamp of the last packet. * * @var int The timestamp the last packet was constructed. */ - protected $timestamp = 0; + public ?int $timestamp = 0; /** - * The Voice WebSocket mode. + * Are we currently speaking? * - * @var string The voice mode. + * @var bool */ - protected $mode = 'xsalsa20_poly1305'; - - /** - * The secret key used for encrypting voice. - * - * @var string The secret key. - */ - protected $secret_key; - - /** - * Are we currently set as speaking? - * - * @var bool Whether we are speaking or not. - */ - protected $speaking = false; - - /** - * Whether we are set as mute. - * - * @var bool Whether we are set as mute. - */ - protected $mute = false; - - /** - * Whether we are set as deaf. - * - * @var bool Whether we are set as deaf. - */ - protected $deaf = false; + public bool $speaking = false; /** * Whether the voice client is currently paused. * - * @var bool Whether the voice client is currently paused. + * @var bool */ - protected $paused = false; + public bool $paused = false; /** * Have we sent the login frame yet? * * @var bool Whether we have sent the login frame. */ - protected $sentLoginFrame = false; + public bool $sentLoginFrame = false; /** * The time we started sending packets. * - * @var int The time we started sending packets. + * @var float|int|null The time we started sending packets. */ - protected $startTime; - - /** - * The stream time of the last packet. - * - * @var int The time we sent the last packet. - */ - protected $streamTime = 0; + public null|float|int $startTime; /** * The size of audio frames, in milliseconds. * * @var int The size of audio frames. */ - protected $frameSize = 20; + public int $frameSize = 20; /** * Collection of the status of people speaking. * - * @var ExCollectionInterface 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. * - * @var array Voice audio recieve streams. + * @deprecated 10.5.0 Use receiveStreams instead. + * + * @var array|null Voice audio recieve streams. */ - protected $recieveStreams; + public ?array $recieveStreams; + + /** + * Voice audio receive streams. + * + * @var array|null Voice audio recieve streams. + */ + 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. @@ -290,426 +196,132 @@ 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; + public 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; - - /** - * The logger. - * - * @var LoggerInterface Logger. - */ - protected $logger; - - /** - * The Discord voice gateway version. - * - * @var int Voice version. - */ - protected $version = 4; + public bool $userClose = false; /** * The Config for DNS Resolver. * - * @var string|\React\Dns\Config\Config - */ - protected $dnsConfig; - - /** - * Silence Frame Remain Count. - * - * @var int Amount of silence frames remaining. + * @var Config|string|null */ - protected $silenceRemaining = 5; + public null|string|Config $dnsConfig; /** * readopus Timer. * * @var TimerInterface Timer */ - protected $readOpusTimer; + public TimerInterface $readOpusTimer; /** * Audio Buffer. * - * @var RealBuffer The Audio Buffer + * @var RealBuffer|null The Audio Buffer */ - protected $buffer; + public null|RealBuffer $buffer; /** - * Constructs the Voice Client instance. + * Current clients connected to the voice chat * - * @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. + * @var array */ - 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']; - $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - $this->speakingStatus = new Collection([], 'ssrc'); - $this->dnsConfig = $data['dnsConfig']; - } + public array $clientsConnected = []; /** - * Starts the voice client. - * - * @return void|bool + * @var TimerInterface */ - public function start() - { - if ( - ! $this->checkForFFmpeg() || - ! $this->checkForLibsodium() - ) { - return false; - } - - $this->initSockets(); - } + public $monitorProcessTimer; /** - * Initilizes the WebSocket and UDP socket. + * Users in the current voice channel. + * + * @var array Users in the current voice channel. */ - public function initSockets(): void - { - $wsfac = new WsFactory($this->loop); - /** @var PromiseInterface */ - $promise = $wsfac("wss://{$this->endpoint}?v={$this->version}"); - - $promise->then([$this, 'handleWebSocketConnection'], [$this, 'handleWebSocketError']); - } + public array $users; /** - * Handles a WebSocket connection. + * Time in which the streaming started. * - * @param WebSocket $ws The WebSocket instance. + * @var int */ - public function handleWebSocketConnection(WebSocket $ws): void - { - $this->logger->debug('connected to voice websocket'); - - $resolver = (new DNSFactory())->createCached($this->dnsConfig, $this->loop); - $udpfac = new DatagramFactory($this->loop, $resolver); - - $this->voiceWebsocket = $ws; - - $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); - - $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]); - }); - } - }; - - $ws->on('message', $discoverUdp); - $ws->on('message', function ($message) { - $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; - $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]); - 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->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]); - } - - break; - case Op::VOICE_SPEAKING: // user started speaking - $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; - - $sendHeartbeat = function () { - $this->send(Payload::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->heartbeat_interval / 1000, $sendHeartbeat); - 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; - } - }); - - $ws->on('error', function ($e) { - $this->logger->error('error with voice websocket', ['e' => $e->getMessage()]); - $this->emit('ws-error', [$e]); - }); - - $ws->on('close', [$this, 'handleWebSocketClose']); - - if (! $this->sentLoginFrame) { - $payload = Payload::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->logger->debug('sending identify', ['packet' => $payload]); - - $this->send($payload); - $this->sentLoginFrame = true; - } - } + public int $streamTime = 0; /** - * Handles a WebSocket error. + * Whether the current voice client is enabled to record audio. * - * @param \Exception $e The error. + * @var bool */ - public function handleWebSocketError(\Exception $e): void - { - $this->logger->error('error with voice websocket', ['e' => $e->getMessage()]); - $this->emit('error', [$e]); - } + protected bool $shouldRecord = false; /** - * Handles a WebSocket close. + * Constructs the Voice client instance * - * @param int $op - * @param string $reason + * @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 + * @param Deferred|null $deferred + * @param VoiceManager|null $manager */ - 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]); + public function __construct( + 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, + protected bool $shouldBoot = true + ) { + $this->deaf = $this->data['deaf'] ?? false; + $this->mute = $this->data['mute'] ?? false; - // Cancel heartbeat timers - if (null !== $this->heartbeat) { - $this->loop->cancelTimer($this->heartbeat); - $this->heartbeat = null; - } - - if (null !== $this->udpHeartbeat) { - $this->loop->cancelTimer($this->udpHeartbeat); - $this->udpHeartbeat = null; - } - - // Close UDP socket. - if ($this->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->emit('close'); - } else { - $this->logger->warning('reconnecting in 2 seconds'); - - // Retry connect after 2 seconds - $this->loop->addTimer(2, function () { - $this->reconnecting = true; - $this->sentLoginFrame = false; + $this->data = [ + 'user_id' => $this->bot->id, + 'deaf' => $this->deaf, + 'mute' => $this->mute, + 'session' => $this->data['session'] ?? null, + ]; - $this->initSockets(); - }); + if ($this->shouldBoot) { + $this->boot(); } } /** - * Handles a voice server change. + * Starts the voice client. * - * @param array $data New voice server information. + * @return bool */ - public function handleVoiceServerChange(array $data = []): void + public function start(): bool { - $this->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->loop->cancelTimer($this->heartbeat); - $this->loop->cancelTimer($this->udpHeartbeat); - - $this->data['token'] = $data['token']; // set the token if it changed - $this->endpoint = str_replace([':80', ':443'], '', $data['endpoint']); - - $this->initSockets(); + if (! Ffmpeg::checkForFFmpeg()) { + return false; + } - $this->on('resumed', function () { - $this->logger->debug('voice client resumed'); - $this->unpause(); - $this->speaking = false; - $this->setSpeaking(true); - }); + WS::make($this); + return true; } /** @@ -726,27 +338,28 @@ 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(); } - $process = $this->ffmpegEncode($file); - $process->start($this->loop); + $process = Ffmpeg::encode($file, volume: $this->getDbVolume()); + $process->start($this->bot->loop); return $this->playOggStream($process); } @@ -786,15 +399,15 @@ 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->bot->loop); } - $process = $this->ffmpegEncode(preArgs: [ + $process = Ffmpeg::encode(volume: $this->getDbVolume(), preArgs: [ '-f', 's16le', '-ac', $channels, '-ar', $audioRate, ]); - $process->start($this->loop); + $process->start($this->bot->loop); $stream->pipe($process->stdin); return $this->playOggStream($process); @@ -839,7 +452,7 @@ public function playOggStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new ReadableResourceStream($stream, $this->loop); + $stream = new Stream($stream, $this->bot->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -848,7 +461,7 @@ public function playOggStream($stream): PromiseInterface return $deferred->promise(); } - $this->buffer = new RealBuffer($this->loop); + $this->buffer = new RealBuffer($this->bot->loop); $stream->on('data', function ($d) { $this->buffer->write($d); }); @@ -858,61 +471,69 @@ 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->bot->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; - } + /** + * 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; - $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->udp->insertSilence(); + $this->readOpusTimer = $this->bot->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->udp->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->bot->loop->addTimer($delay, fn () => $this->readOggOpus($deferred, $ogg, $loops)); + }, function () use ($deferred) { + $this->reset(); + $deferred->resolve(null); + }); } /** @@ -955,7 +576,7 @@ public function playDCAStream($stream): PromiseInterface } if (is_resource($stream)) { - $stream = new ReadableResourceStream($stream, $this->loop); + $stream = new Stream($stream, $this->bot->loop); } if (! ($stream instanceof ReadableStreamInterface)) { @@ -964,53 +585,16 @@ public function playDCAStream($stream): PromiseInterface return $deferred->promise(); } - $this->buffer = new RealBuffer($this->loop); + $this->buffer = new RealBuffer($this->bot->loop); $stream->on('data', function ($d) { $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 $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.'); } @@ -1019,7 +603,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) { @@ -1027,19 +611,64 @@ public function playDCAStream($stream): PromiseInterface } $this->startTime = microtime(true) + 0.5; - $this->readOpusTimer = $this->loop->addTimer(0.5, $readOpus); + $this->readOpusTimer = $this->bot->loop->addTimer(0.5, fn () => $this->readDCAOpus($deferred)); }); return $deferred->promise(); } + /** + * 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; + + // If the client is paused, delay by frame size and check again. + if ($this->paused) { + $this->udp->insertSilence(); + $this->readOpusTimer = $this->bot->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->udp->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->bot->loop->addTimer(($this->frameSize - 1) / 1000, fn () => $this->readDCAOpus($deferred)); + }, function () use ($deferred) { + $this->reset(); + $deferred->resolve(null); + }); + } + /** * Resets the voice client. */ - private function reset(): void + protected function reset(): void { if ($this->readOpusTimer) { - $this->loop->cancelTimer($this->readOpusTimer); + $this->bot->loop->cancelTimer($this->readOpusTimer); $this->readOpusTimer = null; } @@ -1050,25 +679,6 @@ private function reset(): void $this->silenceRemaining = 5; } - /** - * Sends a buffer to the UDP socket. - * - * @param string $data The data to send to the UDP server. - */ - private function sendBuffer(string $data): void - { - if (! $this->ready) { - return; - } - - $packet = new VoicePacket($data, $this->ssrc, $this->seq, $this->timestamp, true, $this->secret_key); - $this->client->send((string) $packet); - - $this->streamTime = microtime(true); - - $this->emit('packet-sent', [$packet]); - } - /** * Sets the speaking value of the client. * @@ -1086,11 +696,12 @@ public function setSpeaking(bool $speaking = true): void throw new \RuntimeException('Voice Client is not ready.'); } - $this->send(Payload::new( + $this->ws->send(VoicePayload::new( Op::VOICE_SPEAKING, [ 'speaking' => $speaking, 'delay' => 0, + 'ssrc' => $this->ssrc, ], )); @@ -1100,27 +711,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}."); } - $this->mainSend(Payload::new( + // 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; + } + + /** + * Disconnects the bot from the current voice channel. + * + * @return \Discord\Voice\VoiceClient + */ + public function disconnect(): static + { + $this->switchChannel(null); + + return $this; } /** @@ -1188,26 +819,14 @@ 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. - */ - private function send(Payload|array $data): void - { - $json = json_encode($data); - $this->voiceWebsocket->send($json); - } - /** * Sends a message to the main websocket. * * @param Payload $data The data to send to the main WebSocket. */ - private function mainSend(Payload $data): void + protected function mainSend($data): void { - $json = json_encode($data); - $this->mainWebsocket->send($json); + $this->bot->send($data); } /** @@ -1227,7 +846,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, @@ -1237,10 +856,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']); } } @@ -1260,7 +879,7 @@ public function pause(): void } $this->paused = true; - $this->silenceRemaining = 5; + $this->udp->refreshSilenceFrames(); } /** @@ -1294,7 +913,7 @@ public function stop(): void } $this->buffer->end(); - $this->insertSilence(); + $this->udp->insertSilence(); $this->reset(); } @@ -1316,7 +935,26 @@ public function close(): void $this->ready = false; - $this->mainSend(Payload::new( + // 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->mainSend(VoicePayload::new( Op::OP_VOICE_STATE_UPDATE, [ 'guild_id' => $this->channel->guild_id, @@ -1327,21 +965,16 @@ public function close(): void )); $this->userClose = true; - $this->client->close(); - $this->voiceWebsocket->close(); + $this->ws->close(); + $this->udp->close(); - $this->heartbeat_interval = null; + $this->heartbeatInterval = null; if (null !== $this->heartbeat) { - $this->loop->cancelTimer($this->heartbeat); + $this->bot->loop->cancelTimer($this->heartbeat); $this->heartbeat = null; } - if (null !== $this->udpHeartbeat) { - $this->loop->cancelTimer($this->udpHeartbeat); - $this->udpHeartbeat = null; - } - $this->seq = 0; $this->timestamp = 0; $this->sentLoginFrame = false; @@ -1361,25 +994,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; - } - - /** - * Checks if we are paused. - * - * @return bool Whether we are paused. - */ - public function isPaused(): bool - { - return $this->paused; + 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, + }; } /** @@ -1387,23 +1007,10 @@ 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 { - $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) { @@ -1414,7 +1021,31 @@ public function handleVoiceStateUpdate(object $data): void return; // ignore, just a mute/deaf change } - $removeDecoder($ss); + $this->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; + + if (null === $decoder) { + return; // no voice decoder to remove + } + + $decoder->close(); + unset( + $this->voiceDecoders[$ss->ssrc], + $this->speakingStatus[$ss->ssrc], + $this->receiveStreams[$ss->ssrc] + ); } /** @@ -1422,17 +1053,31 @@ public function handleVoiceStateUpdate(object $data): void * * @param int|string $id Either a SSRC or User ID. * - * @return RecieveStream + * @deprecated 10.5.0 Use getReceiveStream instead. + * + * @return RecieveStream|ReceiveStream|null */ - public function getRecieveStream($id): ?RecieveStream + public function getRecieveStream($id) { - 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 ReceiveStream|null + */ + public function getReceiveStream($id) + { + if (isset($this->receiveStreams[$id])) { + return $this->receiveStreams[$id]; } foreach ($this->speakingStatus as $status) { - if ($status->user_id == $id) { - return $this->recieveStreams[$status->ssrc]; + if ($status?->user_id == $id) { + return $this->receiveStreams[$status?->ssrc]; } } @@ -1442,185 +1087,143 @@ public function getRecieveStream($id): ?RecieveStream /** * 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. */ - protected function handleAudioData(string $message): void + public function handleAudioData(Packet $voicePacket): 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); + if (! $this->shouldRecord) { + // If we are not recording, we don't need to handle audio data. + return; + } + + $message = $voicePacket?->decryptedAudio ?? null; - if ($message === false) { - // if we can't decode the message, drop it silently. + 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; } $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->bot->logger->warning('Unknown SSRC.', ['ssrc' => $voicePacket->getSSRC(), 't' => $voicePacket->getTimestamp()]); return; } if (null === $decoder) { // 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) { $this->emit('channel-pcm', [$d, $this]); }); - $this->recieveStreams[$ss->ssrc]->on('opus', function ($d) { + $this->receiveStreams[$ss->ssrc]->on('opus', function ($d) { $this->emit('channel-opus', [$d, $this]); }); } - $createDecoder = function () use (&$createDecoder, $ss) { - $decoder = $this->dcaDecode(); - $decoder->start($this->loop); - - $decoder->stdout->on('data', function ($data) use ($ss) { - $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]); - }); - $decoder->on('exit', function ($code, $term) use ($ss, &$createDecoder) { - if ($code > 0) { - $this->emit('decoder-error', [$code, $term, $ss]); - - $createDecoder(); - } - }); - - $this->voiceDecoders[$ss->ssrc] = $decoder; - }; - - $createDecoder(); - $decoder = $this->voiceDecoders[$vp->getSSRC()] ?? null; + $this->createDecoder($ss); + $decoder = $this->voiceDecoders[$ss->ssrc] ?? null; } - $buff = new Buffer(strlen($vp->getData()) + 2); - $buff->write(pack('s', strlen($vp->getData())), 0); - $buff->write($vp->getData(), 2); - - $decoder->stdin->write((string) $buff); - } - - private function handleDavePrepareTransition($data) - { - $this->logger->debug('DAVE Prepare Transition', ['data' => $data]); - // Prepare local state necessary to perform the transition - $this->send(Payload::new( - Op::VOICE_DAVE_TRANSITION_READY, - [ - 'transition_id' => $data->d->transition_id, - ], - )); - } + 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. + // This should be either restarted or checked if the decoder is still running. + } - private function handleDaveExecuteTransition($data) - { - $this->logger->debug('DAVE Execute Transition', ['data' => $data]); - // Execute the transition - // Update local state to reflect the new protocol context - } + if ( + empty($voicePacket->decryptedAudio) + || $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 + } - private function handleDaveTransitionReady($data) - { - $this->logger->debug('DAVE Transition Ready', ['data' => $data]); - // Handle transition ready state - } + $data = OpusFfi::decode($voicePacket->decryptedAudio); - 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( - Op::VOICE_DAVE_MLS_KEY_PACKAGE, - [ - 'epoch_id' => $data->d->epoch_id, - 'key_package' => $this->generateKeyPackage(), - ], - )); - } - - private function handleDaveMlsExternalSender($data) - { - $this->logger->debug('DAVE MLS External Sender', ['data' => $data]); - // Handle external sender public key and credential - } + if (empty(trim($data))) { + logger()->debug('Received empty audio data.', ['ssrc' => $ss->ssrc]); + return; // no audio data to write + } - private function handleDaveMlsKeyPackage($data) - { - $this->logger->debug('DAVE MLS Key Package', ['data' => $data]); - // Handle MLS key package + $decoder->stdin->write($data); } - private function handleDaveMlsProposals($data) + /** + * 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 { - $this->logger->debug('DAVE MLS Proposals', ['data' => $data]); - // Handle MLS proposals - $this->send(Payload::new( - Op::VOICE_DAVE_MLS_COMMIT_WELCOME, - [ - 'commit' => $this->generateCommit(), - 'welcome' => $this->generateWelcome(), - ], - )); - } + $decoder = Ffmpeg::decode((string) $ss->ssrc); + $decoder->start(loop()); - private function handleDaveMlsCommitWelcome($data) - { - $this->logger->debug('DAVE MLS Commit Welcome', ['data' => $data]); - // Handle MLS commit and welcome messages - } + $decoder->stdout->on('data', function ($data) use ($ss) { + if (empty($data)) { + return; // no data to process, should be ignored + } - private function handleDaveMlsAnnounceCommitTransition($data) - { - // Handle MLS announce commit transition - $this->logger->debug('DAVE MLS Announce Commit Transition', ['data' => $data]); - } + // Emit the decoded opus data + $this->receiveStreams[$ss->ssrc]->writeOpus($data); + }); - private function handleDaveMlsWelcome($data) - { - // Handle MLS welcome message - $this->logger->debug('DAVE MLS Welcome', ['data' => $data]); - } + $decoder->stderr->on('data', function ($data) use ($ss) { + if (empty($data)) { + return; // no data to process + } - 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( - Op::VOICE_DAVE_MLS_KEY_PACKAGE, - [ - 'key_package' => $this->generateKeyPackage(), - ], - )); - } + $this->emit("voice.{$ss->ssrc}.stderr", [$data, $this]); + $this->emit("voice.{$ss->user_id}.stderr", [$data, $this]); + }); - private function generateKeyPackage() - { - // Generate and return a new MLS key package - } + // Store the decoder + $this->voiceDecoders[$ss->ssrc] = $decoder; - private function generateCommit() - { - // Generate and return an MLS commit message + // Monitor the process for exit + $this->monitorProcessExit($decoder, $ss); } - private function generateWelcome() + /** + * 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 { - // Generate and return an MLS welcome message + // 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); + } + } + }); } /** @@ -1633,160 +1236,111 @@ public function isReady(): bool return $this->ready; } - /** - * Checks if FFmpeg is installed. - * - * @return bool Whether FFmpeg is installed or not. - */ - private function checkForFFmpeg(): bool + public function getDbVolume(): float|int { - $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; + return match($this->volume) { + 0 => -100, + 100 => 0, + default => -40 + ($this->volume / 100) * 40, + }; } /** - * Checks if libsodium-php is installed. + * Creates a new voice client instance statically * - * @return bool + * @param \Discord\Discord $bot + * @param \Discord\Parts\Channel\Channel $channel + * @param array $data + * @param bool $deaf + * @param bool $mute + * @param null|Deferred $deferred + * @param null|VoiceManager $manager + * @param bool $shouldBoot Whether the client should boot immediately. + * + * @return \Discord\Voice\VoiceClient */ - private function checkForLibsodium(): bool + public static function make(): self { - if (! function_exists('sodium_crypto_secretbox')) { - $this->emit('error', [new LibSodiumNotFoundException('libsodium-php could not be found.')]); - - return false; - } - - return true; + return new static(...func_get_args()); } /** - * Checks if an executable exists on the system. + * Boots the voice client and sets up event listeners. * - * @param string $executable - * @return string|null + * @return bool */ - private static function checkForExecutable(string $executable): ?string + public function boot(): bool { - $which = strtoupper(substr(PHP_OS, 0, 3)) === 'WIN' ? 'where' : 'command -v'; - $executable = rtrim((string) explode(PHP_EOL, shell_exec("{$which} {$executable}"))[0]); + return $this->once('ready', function () { + $this->bot->getLogger()->info('voice client is ready'); + if (isset($this->manager->clients[$this->channel->guild_id])) { + $this->disconnect(); + } - return is_executable($executable) ? $executable : null; + $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->disconnect(); + $this->bot->getLogger()->error('error initializing voice client', ['e' => $e->getMessage()]); + $this->deferred->reject($e); + }) + ->once('close', function () { + $this->disconnect(); + $this->bot->getLogger()->warning('voice client closed'); + unset($this->manager->clients[$this->channel->guild_id]); + }) + ->start(); } - /** - * 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 + public function record(): void { - $dB = 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); + if ($this->shouldRecord) { + throw new \RuntimeException('Already recording audio.'); } - $flags = implode(' ', $flags); - $cmd = "{$this->ffmpeg} {$flags}"; + $this->shouldRecord = true; + $this->bot->getLogger()->info('Started recording audio.'); - return new Process($cmd, null, null, [ - ['socket'], - ['socket'], - ['socket'], - ]); + $this->udp->on('message', [$this, 'handleAudioData']); } - /** - * 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 + public function stopRecording(): void { - if (null === $frameSize) { - $frameSize = round($this->frameSize * 48); + if (! $this->shouldRecord) { + throw new \RuntimeException('Not recording audio.'); } - $flags = [ - '-ac', $channels, // Channels - '-ab', round($this->bitrate / 1000), // Bitrate - '-as', $frameSize, // Frame Size - '-mode', 'decode', // Decode mode - ]; + $this->shouldRecord = false; + $this->bot->getLogger()->info('Stopped recording audio.'); + + $this->udp->removeListener('message', [$this, 'handleAudioData']); + $this->reset(); - $flags = implode(' ', $flags); + foreach ($this->voiceDecoders as $decoder) { + $decoder->close(); + } - return new Process("{$this->dca} {$flags}"); + $this->voiceDecoders = []; + $this->receiveStreams = []; + $this->speakingStatus = Collection::for(VoiceSpeaking::class, 'ssrc'); } - /** - * Returns the connected channel. - * - * @return Channel The connected channel. - */ - public function getChannel(): Channel + public function setData(array $data): self { - return $this->channel; - } + $this->data = $data; - /** - * Insert 5 frames of silence. - * - * @link https://discord.com/developers/docs/topics/voice-connections#voice-data-interpolation - */ - private function insertSilence(): void - { - while (--$this->silenceRemaining > 0) { - $this->sendBuffer(self::SILENCE_FRAME); + 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 new file mode 100644 index 000000000..537ad6362 --- /dev/null +++ b/src/Discord/Voice/VoiceManager.php @@ -0,0 +1,190 @@ + $clients + */ + public function __construct( + protected Discord $bot, + public array $clients = [], + ) { + } + + /** + * 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 + * + * @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 joinChannel(Channel $channel, Discord $discord, bool $mute = false, bool $deaf = true): PromiseInterface + { + $deferred = new Deferred(); + + try { + if (! $channel->isVoiceBased()) { + throw new ChannelMustAllowVoiceException(); + } + + if (! $channel->canJoin()) { + throw new EnterChannelDeniedException(); + } + + if (! $channel->canSpeak() && ! $mute) { + 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(); + } + } catch (\Throwable $th) { + $deferred->reject($th); + return $deferred->promise(); + } + + // 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->on(Event::VOICE_SERVER_UPDATE, fn ($state, Discord $discord) => $this->serverUpdate($state, $channel, $discord, $deferred)); + + $discord->send(VoicePayload::new( + Op::OP_VOICE_STATE_UPDATE, + [ + 'guild_id' => $channel->guild_id, + 'channel_id' => $channel->id, + 'self_mute' => $mute, + 'self_deaf' => $deaf, + ], + )); + + return $deferred->promise(); + } + + /** + * 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 ($guildChannelOrId instanceof Channel) { + $guildChannelOrId = $guildChannelOrId->guild_id; + } + + if (! isset($this->clients[$guildChannelOrId])) { + return null; + } + + return $this->clients[$guildChannelOrId]; + } + + /** + * 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->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]); + } + + /** + * 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. + } + + $this->bot->getLogger()->info('received token and endpoint for voice session', [ + 'guild' => $channel->guild_id, + 'token' => $state->token, + 'endpoint' => $state->endpoint + ]); + + $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']]) + ); + } + +} diff --git a/src/Discord/Voice/VoicePacket.php b/src/Discord/Voice/VoicePacket.php deleted file mode 100644 index 9f7df8045..000000000 --- a/src/Discord/Voice/VoicePacket.php +++ /dev/null @@ -1,237 +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; - -/** - * A voice packet received from Discord. - * - * Huge thanks to Austin and Michael from JDA for the constants and audio - * packets. Check out their repo: - * https://github.com/DV8FromTheWorld/JDA - * - * @since 3.2.0 - */ -class VoicePacket -{ - 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; - - /** - * The voice packet buffer. - * - * @var Buffer - */ - protected $buffer; - - /** - * The client SSRC. - * - * @var int The client SSRC. - */ - protected $ssrc; - - /** - * The packet sequence. - * - * @var int The packet sequence. - */ - protected $seq; - - /** - * The packet timestamp. - * - * @var int The packet timestamp. - */ - protected $timestamp; - - /** - * Constructs the voice packet. - * - * @param string $data The Opus data to encode. - * @param int $ssrc The client SSRC value. - * @param int $seq The packet sequence. - * @param int $timestamp The packet timestamp. - * @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) - { - $this->ssrc = $ssrc; - $this->seq = $seq; - $this->timestamp = $timestamp; - - if (! $encryption) { - $this->initBufferNoEncryption($data); - } else { - $this->initBufferEncryption($data, $key); - } - } - - /** - * Initilizes the buffer with no encryption. - * - * @param string $data The Opus data to encode. - */ - 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; - } - - /** - * 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 - { - $data = (string) $data; - $header = $this->buildHeader(); - $nonce = new Buffer(24); - $nonce->write((string) $header, 0); - - $data = \sodium_crypto_secretbox($data, (string) $nonce, $key); - - $this->buffer = new Buffer(strlen((string) $header) + strlen($data)); - $this->buffer->write((string) $header, 0); - $this->buffer->write($data, 12); - } - - /** - * Builds the header. - * - * @return Buffer The header. - */ - 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); - - return $header; - } - - /** - * Returns the sequence. - * - * @return int The packet sequence. - */ - public function getSequence(): int - { - return $this->seq; - } - - /** - * Returns the timestamp. - * - * @return int The packet timestamp. - */ - public function getTimestamp(): int - { - return $this->timestamp; - } - - /** - * Returns the SSRC. - * - * @return int The packet SSRC. - */ - 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. - * - * @return string The packet data. - */ - public function getData(): string - { - return $this->buffer->read(self::RTP_HEADER_BYTE_LENGTH, strlen((string) $this->buffer) - self::RTP_HEADER_BYTE_LENGTH); - } - - /** - * Creates a voice packet from data sent from Discord. - * - * @param string $data Data from Discord. - * - * @return VoicePacket A voice packet. - */ - public static function make(string $data): VoicePacket - { - $n = new self('', 0, 0, 0); - $buff = new Buffer($data); - $n->setBuffer($buff); - - return $n; - } - - /** - * Sets the buffer. - * - * @param Buffer $buffer The buffer to set. - * - * @return $this - */ - 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); - - return $this; - } - - /** - * Handles to string casting of object. - * - * @return string - */ - public function __toString(): string - { - return (string) $this->buffer; - } -} diff --git a/src/Discord/WebSockets/Op.php b/src/Discord/WebSockets/Op.php index d9d1e12e2..319adb706 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,15 @@ 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_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*/ + 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. */ 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('/(?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); } diff --git a/src/Discord/WebSockets/VoicePayload.php b/src/Discord/WebSockets/VoicePayload.php new file mode 100644 index 000000000..64bffbe2c --- /dev/null +++ b/src/Discord/WebSockets/VoicePayload.php @@ -0,0 +1,85 @@ + + * + * 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 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(); + + 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; + } +} 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. */