diff --git a/SECURITY.md b/SECURITY.md index d56568498..abf462aab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,8 +5,7 @@ | Version | Supported | | ------- | ------------------ | | 8.x | :white_check_mark: | -| 7.x | :white_check_mark: | -| < 7.x | :x: | +| < 8.x | :x: | ## Reporting a Vulnerability diff --git a/examples/bot.php b/examples/bot.php index fa1a32515..a9cb70642 100755 --- a/examples/bot.php +++ b/examples/bot.php @@ -40,6 +40,7 @@ use danog\MadelineProto\Settings\Database\Mysql; use danog\MadelineProto\Settings\Database\Postgres; use danog\MadelineProto\Settings\Database\Redis; use danog\MadelineProto\SimpleEventHandler; +use danog\MadelineProto\VoIP; // MadelineProto is already loaded if (class_exists(API::class)) { @@ -56,6 +57,9 @@ if (class_exists(API::class)) { /** * Event handler class. * + * NOTE: ALL of the following methods are OPTIONAL. + * You can even provide an empty event handler if you want. + * * All properties returned by __sleep are automatically stored in the database. */ class MyEventHandler extends SimpleEventHandler @@ -228,7 +232,7 @@ class MyEventHandler extends SimpleEventHandler $message->reply($args[0] ?? ''); } - #[FilterRegex('/.*(mt?proto).*/i')] + #[FilterRegex('/.*(mt?proto)[^.]?.*/i')] public function testRegex(Incoming & Message $message): void { $message->reply("Did you mean to write MadelineProto instead of ".$message->matches[1].'?'); @@ -288,6 +292,18 @@ class MyEventHandler extends SimpleEventHandler $reply->reply("Download link: ".$reply->media->getDownloadLink()); } + #[FilterCommand('call')] + public function callVoip(Incoming&Message $message): void + { + $this->requestCall($message->senderId)->play(__DIR__.'/../music.ogg'); + } + + #[Handler] + public function handleIncomingCall(VoIP $call): void + { + $call->accept()->play(__DIR__.'/../music.ogg'); + } + public static function getPluginPaths(): string|array|null { return 'plugins/'; diff --git a/src/EventHandler/Filter/FilterIncoming.php b/src/EventHandler/Filter/FilterIncoming.php index ba2beb12b..53ab9bc6a 100644 --- a/src/EventHandler/Filter/FilterIncoming.php +++ b/src/EventHandler/Filter/FilterIncoming.php @@ -5,6 +5,8 @@ namespace danog\MadelineProto\EventHandler\Filter; use Attribute; use danog\MadelineProto\EventHandler\AbstractMessage; use danog\MadelineProto\EventHandler\Update; +use danog\MadelineProto\VoIP; +use danog\MadelineProto\VoIP\CallState; /** * Allow only incoming messages. @@ -14,6 +16,8 @@ final class FilterIncoming extends Filter { public function apply(Update $update): bool { - return $update instanceof AbstractMessage && !$update->out; + return ($update instanceof AbstractMessage && !$update->out) + || ($update instanceof VoIP && $update->getCallState() === CallState::INCOMING) + ; } } diff --git a/src/Loop/Connection/CheckLoop.php b/src/Loop/Connection/CheckLoop.php index cf70c2525..a9b2500f4 100644 --- a/src/Loop/Connection/CheckLoop.php +++ b/src/Loop/Connection/CheckLoop.php @@ -126,6 +126,7 @@ final class CheckLoop extends Loop $list = ''; // Don't edit this here pls foreach ($message_ids as $message_id) { + if (!isset($this->connection->outgoing_messages[$message_id])) continue; $list .= $this->connection->outgoing_messages[$message_id]->getConstructor().', '; } $this->logger->logger("Still missing {$list} on DC {$this->datacenter}, sending state request", Logger::ERROR); diff --git a/src/MTProto.php b/src/MTProto.php index 55845b5bf..0384bfca9 100644 --- a/src/MTProto.php +++ b/src/MTProto.php @@ -59,6 +59,7 @@ use danog\MadelineProto\TL\TL; use danog\MadelineProto\TL\TLCallback; use danog\MadelineProto\TL\TLInterface; use danog\MadelineProto\TL\Types\LoginQrCode; +use danog\MadelineProto\VoIP\CallState; use danog\MadelineProto\Wrappers\Ads; use danog\MadelineProto\Wrappers\Button; use danog\MadelineProto\Wrappers\DialogHandler; @@ -293,10 +294,6 @@ final class MTProto implements TLCallback, LoggerGetter * Config loop. */ public ?PeriodicLoopInternal $configLoop = null; - /** - * Call checker loop. - */ - private ?PeriodicLoopInternal $callCheckerLoop = null; /** * Autoserialization loop. */ @@ -820,7 +817,6 @@ final class MTProto implements TLCallback, LoggerGetter */ private function startLoops(): void { - $this->callCheckerLoop ??= new PeriodicLoopInternal($this, $this->checkCalls(...), 'call check', 10); $this->serializeLoop ??= new PeriodicLoopInternal($this, $this->serialize(...), 'serialize', $this->settings->getSerialization()->getInterval()); $this->phoneConfigLoop ??= new PeriodicLoopInternal($this, $this->getPhoneConfig(...), 'phone config', 3600); $this->configLoop ??= new PeriodicLoopInternal($this, $this->getConfig(...), 'config', 3600); @@ -834,7 +830,6 @@ final class MTProto implements TLCallback, LoggerGetter $this->logger->logger("Error while starting IPC server: $e", Logger::FATAL_ERROR); } } - $this->callCheckerLoop->start(); $this->serializeLoop->start(); $this->phoneConfigLoop->start(); $this->configLoop->start(); @@ -852,10 +847,6 @@ final class MTProto implements TLCallback, LoggerGetter */ private function stopLoops(): void { - if ($this->callCheckerLoop) { - $this->callCheckerLoop->stop(); - $this->callCheckerLoop = null; - } if ($this->serializeLoop) { $this->serializeLoop->stop(); $this->serializeLoop = null; @@ -1006,18 +997,6 @@ final class MTProto implements TLCallback, LoggerGetter $this->TL->updateCallbacks($callbacks); - // Clean up phone call array - foreach ($this->calls as $id => $controller) { - if (!\is_object($controller)) { - unset($this->calls[$id]); - } elseif ($controller->getCallState() === VoIP::CALL_STATE_ENDED) { - $controller->setMadeline($this); - $controller->discard(); - } else { - $controller->setMadeline($this); - } - } - $this->settings->getConnection()->init(); // Setup logger $this->setupLogger(); diff --git a/src/MTProtoTools/UpdateHandler.php b/src/MTProtoTools/UpdateHandler.php index d8541340e..0626585f5 100644 --- a/src/MTProtoTools/UpdateHandler.php +++ b/src/MTProtoTools/UpdateHandler.php @@ -58,6 +58,7 @@ use danog\MadelineProto\TL\Types\Button; use danog\MadelineProto\Tools; use danog\MadelineProto\UpdateHandlerType; use danog\MadelineProto\VoIP; +use danog\MadelineProto\VoIP\CallState; use Revolt\EventLoop; use Throwable; use Webmozart\Assert\Assert; @@ -344,6 +345,7 @@ trait UpdateHandler 'updateInlineBotCallbackQuery' => isset($update['game_short_name']) ? new InlineGameQuery($this, $update) : new InlineButtonQuery($this, $update), + 'updatePhoneCall' => $update['phone_call'], default => null }; } @@ -749,38 +751,47 @@ trait UpdateHandler return; } if ($update['_'] === 'updatePhoneCall') { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - $this->logger->logger('The php-libtgvoip extension is required to accept and manage calls. See daniil.it/MadelineProto for more info.', Logger::WARNING); - return; - } switch ($update['phone_call']['_']) { case 'phoneCallRequested': + return; + /*if (!isset($this->calls[$update['phone_call']['id']])) { + $update['phone_call'] = $this->calls[$update['phone_call']['id']] = new VoIP( + $this, + $update['phone_call'], + ); + } + break;*/ + case 'phoneCallWaiting': if (isset($this->calls[$update['phone_call']['id']])) { return; } - $controller = new VoIP(false, $update['phone_call']['admin_id'], $this, VoIP::CALL_STATE_INCOMING); - $controller->setCall($update['phone_call']); - $controller->storage = ['g_a_hash' => $update['phone_call']['g_a_hash']]; - $controller->storage['video'] = $update['phone_call']['video'] ?? false; - $update['phone_call'] = $this->calls[$update['phone_call']['id']] = $controller; + $update['phone_call'] = $this->calls[$update['phone_call']['id']] = new VoIP( + $this, + $update['phone_call'] + ); break; case 'phoneCallAccepted': - if (!($this->confirmCall($update['phone_call']))) { + if (!isset($this->calls[$update['phone_call']['id']])) { return; } - $update['phone_call'] = $this->calls[$update['phone_call']['id']]; + $controller = $this->calls[$update['phone_call']['id']]; + $controller->confirm($update['phone_call']); + $update['phone_call'] = $controller; break; case 'phoneCall': - if (!($this->completeCall($update['phone_call']))) { + if (!isset($this->calls[$update['phone_call']['id']])) { return; } - $update['phone_call'] = $this->calls[$update['phone_call']['id']]; + $controller = $this->calls[$update['phone_call']['id']]; + $controller->complete($update['phone_call']); + $update['phone_call'] = $controller; break; case 'phoneCallDiscarded': if (!isset($this->calls[$update['phone_call']['id']])) { return; } - $update['phone_call'] = $this->calls[$update['phone_call']['id']]->discard($update['phone_call']['reason'] ?? ['_' => 'phoneCallDiscardReasonDisconnect'], [], $update['phone_call']['need_debug'] ?? false); + $update['phone_call'] = $controller = $this->calls[$update['phone_call']['id']]; + $controller->discard(); break; } } diff --git a/src/Magic.php b/src/Magic.php index 2d66ea6fc..e07b60e8b 100644 --- a/src/Magic.php +++ b/src/Magic.php @@ -303,11 +303,6 @@ final class Magic } } self::$BIG_ENDIAN = \pack('L', 1) === \pack('N', 1); - if (\class_exists('\\danog\\MadelineProto\\VoIP')) { - if (!\defined('\\danog\\MadelineProto\\VoIP::PHP_LIBTGVOIP_VERSION') || !\in_array(VoIP::PHP_LIBTGVOIP_VERSION, ['1.5.0'], true)) { - throw new Exception(\hex2bin(Lang::$current_lang['v_tgerror']), 0, null, 'MadelineProto', 1); - } - } self::$hasOpenssl = \extension_loaded('openssl'); self::$emojis = \json_decode(self::JSON_EMOJIS); self::$zero = new BigInteger(0); diff --git a/src/Ogg.php b/src/Ogg.php index 584ecd6dd..7b027610f 100644 --- a/src/Ogg.php +++ b/src/Ogg.php @@ -600,7 +600,7 @@ final class Ogg $writeTag("MadelineProto ".API::RELEASE.", ".$opus->opus_get_version_string()); $tags .= \pack('V', 2); $writeTag("ENCODER=MadelineProto ".API::RELEASE." with ".$opus->opus_get_version_string()); - $writeTag('See https://docs.madelineproto.xyz/docs/VOIP.html for more info'); + $writeTag('See https://docs.madelineproto.xyz/docs/CALLS.html for more info'); $writePage( 0, 0, diff --git a/src/VoIP.php b/src/VoIP.php index b88a4c566..235025a96 100644 --- a/src/VoIP.php +++ b/src/VoIP.php @@ -15,41 +15,28 @@ If not, see . namespace danog\MadelineProto; +use AssertionError; +use danog\MadelineProto\EventHandler\Update; use danog\MadelineProto\MTProto\PermAuthKey; +use danog\MadelineProto\MTProtoTools\Crypt; use danog\MadelineProto\Stream\Common\FileBufferedStream; use danog\MadelineProto\Stream\ConnectionContext; use danog\MadelineProto\VoIP\AckHandler; +use danog\MadelineProto\VoIP\CallState; use danog\MadelineProto\VoIP\Endpoint; use danog\MadelineProto\VoIP\MessageHandler; +use danog\MadelineProto\VoIP\VoIPState; +use phpseclib3\Math\BigInteger; use Revolt\EventLoop; use SplQueue; use Throwable; +use Webmozart\Assert\Assert; use function Amp\delay; use function Amp\File\openFile; -if (\extension_loaded('php-libtgvoip')) { - return; -} - -final class VoIP +final class VoIP extends Update { - use MessageHandler; - use AckHandler; - - const PHP_LIBTGVOIP_VERSION = '1.5.0'; - const STATE_CREATED = 0; - const STATE_WAIT_INIT = 1; - const STATE_WAIT_INIT_ACK = 2; - const STATE_ESTABLISHED = 3; - const STATE_FAILED = 4; - const STATE_RECONNECTING = 5; - - const TGVOIP_ERROR_UNKNOWN = 0; - const TGVOIP_ERROR_INCOMPATIBLE = 1; - const TGVOIP_ERROR_TIMEOUT = 2; - const TGVOIP_ERROR_AUDIO_IO = 3; - const NET_TYPE_UNKNOWN = 0; const NET_TYPE_GPRS = 1; const NET_TYPE_EDGE = 2; @@ -75,194 +62,276 @@ final class VoIP const AUDIO_STATE_CONFIGURED = 1; const AUDIO_STATE_RUNNING = 2; - const CALL_STATE_NONE = -1; - const CALL_STATE_REQUESTED = 0; - const CALL_STATE_INCOMING = 1; - const CALL_STATE_ACCEPTED = 2; - const CALL_STATE_CONFIRMED = 3; - const CALL_STATE_READY = 4; - const CALL_STATE_ENDED = 5; - + /** @internal */ const PKT_INIT = 1; + /** @internal */ const PKT_INIT_ACK = 2; + /** @internal */ const PKT_STREAM_STATE = 3; + /** @internal */ const PKT_STREAM_DATA = 4; + /** @internal */ const PKT_UPDATE_STREAMS = 5; + /** @internal */ const PKT_PING = 6; + /** @internal */ const PKT_PONG = 7; + /** @internal */ const PKT_STREAM_DATA_X2 = 8; + /** @internal */ const PKT_STREAM_DATA_X3 = 9; + /** @internal */ const PKT_LAN_ENDPOINT = 10; + /** @internal */ const PKT_NETWORK_CHANGED = 11; + /** @internal */ const PKT_SWITCH_PREF_RELAY = 12; + /** @internal */ const PKT_SWITCH_TO_P2P = 13; + /** @internal */ const PKT_NOP = 14; + /** @internal */ const TLID_DECRYPTED_AUDIO_BLOCK = "\xc1\xdb\xf9\x48"; + /** @internal */ const TLID_SIMPLE_AUDIO_BLOCK = "\x0d\x0e\x76\xcc"; + /** @internal */ const TLID_REFLECTOR_SELF_INFO = "\xC7\x72\x15\xc0"; + /** @internal */ const TLID_REFLECTOR_PEER_INFO = "\x1C\x37\xD9\x27"; + /** @internal */ const PROTO_ID = 'GrVP'; + /** @internal */ const PROTOCOL_VERSION = 9; + /** @internal */ const MIN_PROTOCOL_VERSION = 9; + /** @internal */ const STREAM_TYPE_AUDIO = 1; + /** @internal */ const STREAM_TYPE_VIDEO = 2; + /** @internal */ const CODEC_OPUS = 'SUPO'; - private MTProto $MadelineProto; - public array $received_timestamp_map = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - public array $remote_ack_timestamp_map = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - public int $session_out_seq_no = 0; - public int $session_in_seq_no = 0; - public int $voip_state = 0; - public array $configuration = ['endpoints' => [], 'shared_config' => []]; - public array $storage = []; - public array $internalStorage = []; - private $signal = 0; - private ?int $callState = null; - private $callID; - private $creatorID; - private $otherID; - private $protocol; - private $visualization; - private $holdFiles = []; - private $inputFiles = []; - private $outputFile; - private $isPlaying = false; + protected MessageHandler $messageHandler; + protected VoIPState $voipState = VoIPState::CREATED; + protected CallState $callState; - private bool $creator; + protected readonly array $call; - private PermAuthKey $authKey; - private int $peerVersion = 0; + /** @var list */ + protected array $holdFiles = []; + /** @var list */ + protected array $inputFiles = []; /** * @var array */ - private array $sockets = []; + protected array $sockets = []; + protected ?Endpoint $bestEndpoint = null; + protected bool $pendingPing = true; + protected ?string $timeoutWatcher = null; + protected ?string $pingWatcher = null; + protected float $lastIncomingTimestamp = 0.0; + protected int $opusTimestamp = 0; + protected SplQueue $packetQueue; + protected array $tempHoldFiles = []; - private ?Endpoint $bestEndpoint = null; + /** Auth key */ + protected readonly string $authKey; + /** Protocol call ID */ + protected readonly string $protocolCallID; - private bool $pendingPing = true; - /** - * Timeout watcher. - */ - private ?string $timeoutWatcher = null; - /** - * Ping watcher. - */ - private ?string $pingWatcher = null; + /** Phone call ID */ + public readonly int $callID; + /** Whether the call is an outgoing call */ + public readonly bool $outgoing; + /** ID of the other user in the call */ + public readonly int $otherID; + /** ID of the creator of the call */ + public readonly int $creatorID; + /** When was the call created */ + public readonly int $date; + /** @var ?list{string, string, string, string} */ + protected ?array $visualization = null; /** - * Last incoming timestamp. - * + * Constructor. + * + * @internal */ - private float $lastIncomingTimestamp = 0.0; - /** - * The outgoing timestamp. - * - */ - private int $timestamp = 0; - /** - * Packet queue. - * - */ - private SplQueue $packetQueue; - /** - * Temporary holdfile array. - */ - private array $tempHoldFiles = []; - /** - * Sleep function. - */ - public function __sleep(): array + public function __construct( + protected readonly MTProto $API, + array $call + ) { - $vars = \get_object_vars($this); - unset($vars['sockets'], $vars['bestEndpoint'], $vars['timeoutWatcher']); + $call['_'] = 'inputPhoneCall'; + $this->packetQueue = new SplQueue; + $this->call = $call; + $this->date = $call['date']; + $this->callID = $call['id']; + if ($call['_'] === 'phoneCallWaiting') { + $this->outgoing = false; + $this->otherID = $call['participant_id']; + $this->creatorID = $call['admin_id']; + $this->callState = CallState::INCOMING; + } else { + $this->outgoing = true; + $this->otherID = $call['admin_id']; + $this->creatorID = $call['participant_id']; + $this->callState = CallState::REQUESTED; + } + } - return \array_keys($vars); + /** + * Confirm requested call. + * @internal + */ + public function confirm(array $params): bool + { + if ($this->callState !== CallState::REQUESTED) { + $this->API->logger->logger(\sprintf(Lang::$current_lang['call_error_2'], $this->callID)); + return false; + } + $this->API->logger->logger(\sprintf(Lang::$current_lang['call_confirming'], $this->otherID), Logger::VERBOSE); + $dh_config = $this->API->getDhConfig(); + $params['g_b'] = new BigInteger((string) $params['g_b'], 256); + Crypt::checkG($params['g_b'], $dh_config['p']); + $key = \str_pad($params['g_b']->powMod($this->call['a'], $dh_config['p'])->toBytes(), 256, \chr(0), STR_PAD_LEFT); + try { + $res = ($this->API->methodCallAsyncRead('phone.confirmCall', ['key_fingerprint' => \substr(\sha1($key, true), -8), 'peer' => ['id' => $params['id'], 'access_hash' => $params['access_hash'], '_' => 'inputPhoneCall'], 'g_a' => $this->call['g_a'], 'protocol' => ['_' => 'phoneCallProtocol', 'udp_reflector' => true, 'min_layer' => 65, 'max_layer' => 92]]))['phone_call']; + } catch (RPCErrorException $e) { + if ($e->rpc === 'CALL_ALREADY_ACCEPTED') { + $this->API->logger->logger(\sprintf(Lang::$current_lang['call_already_accepted'], $params['id'])); + return true; + } + if ($e->rpc === 'CALL_ALREADY_DECLINED') { + $this->API->logger->logger(Lang::$current_lang['call_already_declined']); + $this->discard(['_' => 'phoneCallDiscardReasonHangup']); + return false; + } + throw $e; + } + $visualization = []; + $length = new BigInteger(\count(Magic::$emojis)); + foreach (\str_split(\hash('sha256', $key.\str_pad($this->call['g_a'], 256, \chr(0), STR_PAD_LEFT), true), 8) as $number) { + $number[0] = \chr(\ord($number[0]) & 0x7f); + $visualization[] = Magic::$emojis[(int) (new BigInteger($number, 256))->divide($length)[1]->toString()]; + } + $this->visualization = $visualization; + $this->authKey = $key; + $this->callState = CallState::RUNNING; + $this->protocolCallID = \substr(\hash('sha256', $key, true), -16); + $this->initialize($res['connections']); + return true; + } + /** + * Accept incoming call. + */ + public function accept(): self + { + if ($this->callState === CallState::RUNNING || $this->callState === CallState::ENDED) { + return $this; + } + Assert::eq($this->callState->name, CallState::INCOMING->name); + + $this->API->logger->logger(\sprintf(Lang::$current_lang['accepting_call'], $this->otherID), Logger::VERBOSE); + $dh_config = $this->API->getDhConfig(); + $this->API->logger->logger('Generating b...', Logger::VERBOSE); + $b = BigInteger::randomRange(Magic::$two, $dh_config['p']->subtract(Magic::$two)); + $g_b = $dh_config['g']->powMod($b, $dh_config['p']); + Crypt::checkG($g_b, $dh_config['p']); + try { + $this->API->methodCallAsyncRead('phone.acceptCall', ['peer' => ['id' => $this->call['id'], 'access_hash' => $this->call['access_hash'], '_' => 'inputPhoneCall'], 'g_b' => $g_b->toBytes(), 'protocol' => ['_' => 'phoneCallProtocol', 'udp_reflector' => true, 'udp_p2p' => true, 'min_layer' => 65, 'max_layer' => 92]]); + } catch (RPCErrorException $e) { + if ($e->rpc === 'CALL_ALREADY_ACCEPTED') { + $this->API->logger->logger(\sprintf(Lang::$current_lang['call_already_accepted'], $this->callID)); + return $this; + } + if ($e->rpc === 'CALL_ALREADY_DECLINED') { + $this->API->logger->logger(Lang::$current_lang['call_already_declined']); + $this->discard(['_' => 'phoneCallDiscardReasonHangup']); + return $this; + } + throw $e; + } + $this->call['b'] = $b; + + $this->callState = CallState::ACCEPTED; + + return $this; + } + + /** + * Complete call handshake. + * + * @internal + */ + public function complete(array $params): bool + { + if ($this->callState !== CallState::ACCEPTED) { + $this->API->logger->logger(\sprintf(Lang::$current_lang['call_error_3'], $params['id'])); + return false; + } + $this->API->logger->logger(\sprintf(Lang::$current_lang['call_completing'], $this->otherID), Logger::VERBOSE); + $dh_config = $this->API->getDhConfig(); + if (\hash('sha256', $params['g_a_or_b'], true) != $this->call['g_a_hash']) { + throw new SecurityException('Invalid g_a!'); + } + $params['g_a_or_b'] = new BigInteger((string) $params['g_a_or_b'], 256); + Crypt::checkG($params['g_a_or_b'], $dh_config['p']); + $key = \str_pad($params['g_a_or_b']->powMod($this->call['b'], $dh_config['p'])->toBytes(), 256, \chr(0), STR_PAD_LEFT); + if (\substr(\sha1($key, true), -8) != $params['key_fingerprint']) { + throw new SecurityException(Lang::$current_lang['fingerprint_invalid']); + } + $visualization = []; + $length = new BigInteger(\count(Magic::$emojis)); + foreach (\str_split(\hash('sha256', $key.\str_pad($params['g_a_or_b']->toBytes(), 256, \chr(0), STR_PAD_LEFT), true), 8) as $number) { + $number[0] = \chr(\ord($number[0]) & 0x7f); + $visualization[] = Magic::$emojis[(int) (new BigInteger($number, 256))->divide($length)[1]->toString()]; + } + $this->visualization = $visualization; + $this->authKey = $key; + $this->callState = CallState::RUNNING; + $this->protocolCallID = \substr(\hash('sha256', $key, true), -16); + $this->initialize($params['connections']); + return true; } /** * Wakeup function. */ public function __wakeup(): void { - if ($this->voip_state === self::STATE_ESTABLISHED) { - $this->voip_state = self::STATE_CREATED; - $this->startTheMagic(); + if ($this->callState === CallState::RUNNING) { + $this->startReadLoop(); + if ($this->voipState === VoIPState::ESTABLISHED) { + $this->startWriteLoop(); + } } } - /** - * Constructor. - */ - public function __construct(bool $creator, int $otherID, MTProto $MadelineProto, int $callState) - { - $this->creator = $creator; - $this->otherID = $otherID; - $this->MadelineProto = $MadelineProto; - $this->callState = $callState; - $this->packetQueue = new SplQueue; - } /** - * Get max layer. + * Get call emojis (will return null if the call is not inited yet). + * + * @return ?list{string, string, string, string} */ - public static function getConnectionMaxLayer(): int - { - return 92; - } - - /** - * Get debug string. - */ - public function getDebugString(): string - { - return ''; - } - - /** - * Set call constructor. - */ - public function setCall(array $callID): void - { - $this->protocol = $callID['protocol']; - $this->callID = [ - '_' => 'inputPhoneCall', - 'id' => $callID['id'], - 'access_hash' => $callID['access_hash'], - ]; - } - - /** - * Set emojis. - */ - public function setVisualization(array $visualization): void - { - $this->visualization = $visualization; - } - - /** - * Get emojis. - */ - public function getVisualization(): array + public function getVisualization(): ?array { return $this->visualization; } /** * Discard call. - * */ - public function discard(array $reason = ['_' => 'phoneCallDiscardReasonDisconnect'], array $rating = [], bool $debug = false): self + public function discard(array $reason = ['_' => 'phoneCallDiscardReasonDisconnect'], array $rating = []): self { - if (($this->callState ?? self::CALL_STATE_ENDED) === self::CALL_STATE_ENDED || empty($this->configuration)) { + if ($this->callState === CallState::ENDED) { return $this; } - $this->callState = self::CALL_STATE_ENDED; Logger::log("Now closing $this"); if (isset($this->timeoutWatcher)) { EventLoop::cancel($this->timeoutWatcher); @@ -274,80 +343,67 @@ final class VoIP } Logger::log("Closed all sockets, discarding $this"); - $this->MadelineProto->discardCall($this->callID, $reason, $rating, $debug); - return $this; - } - - public function __destruct() - { - EventLoop::queue($this->discard(...), ['_' => 'phoneCallDiscardReasonDisconnect']); - } - /** - * Accept call. - * - */ - public function accept(): self|false - { - if ($this->callState !== self::CALL_STATE_INCOMING) { - return false; + $this->API->logger->logger(\sprintf(Lang::$current_lang['call_discarding'], $this->callID), Logger::VERBOSE); + try { + $this->API->methodCallAsyncRead('phone.discardCall', ['peer' => $this->call, 'duration' => \time() - $this->calls[$call['id']]->whenCreated(), 'connection_id' => $this->calls[$call['id']]->getPreferredRelayID(), 'reason' => $reason]); + } catch (RPCErrorException $e) { + if (!\in_array($e->rpc, ['CALL_ALREADY_DECLINED', 'CALL_ALREADY_ACCEPTED'], true)) { + throw $e; + } } - $this->callState = self::CALL_STATE_ACCEPTED; - - $res = $this->MadelineProto->acceptCall($this->callID); - - if (!$res) { - $this->discard(['_' => 'phoneCallDiscardReasonDisconnect']); + if (!empty($rating)) { + $this->API->logger->logger(\sprintf('Setting rating for call %s...', $this->call), Logger::VERBOSE); + $this->API->methodCallAsyncRead('phone.setCallRating', ['peer' => $this->call, 'rating' => $rating['rating'], 'comment' => $rating['comment']]); } - + $this->API->cleanupCall($this->callID); + $this->callState = CallState::ENDED; return $this; } /** - * Start the actual call. + * Connect to the specified endpoints. */ - public function startTheMagic(): self + private function initialize(array $endpoints): void { - if ($this->voip_state !== self::STATE_CREATED) { - return $this; + foreach ($endpoints as $endpoint) { + try { + $this->sockets['v6 '.$endpoint['id']] = new Endpoint( + '['.$endpoint['ipv6'].']', + $endpoint['port'], + $endpoint['peer_tag'], + true, + $this->outgoing, + $this->authKey, + $this->protocolCallID, + $this->messageHandler + ); + } catch (Throwable) { + } + try { + $this->sockets['v4 '.$endpoint['id']] = new Endpoint( + $endpoint['ip'], + $endpoint['port'], + $endpoint['peer_tag'], + true, + $this->outgoing, + $this->authKey, + $this->protocolCallID, + $this->messageHandler + ); + } catch (Throwable) { + } + } + $this->voipState = VoIPState::WAIT_INIT; + $this->startReadLoop(); + foreach ($this->sockets as $socket) { + $socket->write($this->messageHandler->encryptPacket([ + '_' => self::PKT_INIT, + 'protocol' => self::PROTOCOL_VERSION, + 'min_protocol' => self::MIN_PROTOCOL_VERSION, + 'audio_streams' => [self::CODEC_OPUS], + 'video_streams' => [] + ], true)); } - $this->voip_state = self::STATE_WAIT_INIT; - $this->timeoutWatcher = EventLoop::repeat(10, function (): void { - if (\microtime(true) - $this->lastIncomingTimestamp > 10) { - $this->discard(['_' => 'phoneCallDiscardReasonDisconnect']); - } - }); - EventLoop::queue(function (): void { - $this->authKey = new PermAuthKey(); - $this->authKey->setAuthKey($this->configuration['auth_key']); - - foreach ($this->configuration['endpoints'] as $endpoint) { - try { - $this->sockets['v6 '.$endpoint['id']] = new Endpoint('['.$endpoint['ipv6'].']', $endpoint['port'], $endpoint['peer_tag'], true, $this); - } catch (Throwable) { - } - try { - $this->sockets['v4 '.$endpoint['id']] = new Endpoint($endpoint['ip'], $endpoint['port'], $endpoint['peer_tag'], true, $this); - } catch (Throwable) { - } - } - foreach ($this->sockets as $socket) { - $socket->write($this->encryptPacket([ - '_' => self::PKT_INIT, - 'protocol' => self::PROTOCOL_VERSION, - 'min_protocol' => self::MIN_PROTOCOL_VERSION, - 'audio_streams' => [self::CODEC_OPUS], - 'video_streams' => [] - ], false)); - EventLoop::queue(function () use ($socket): void { - while ($payload = $socket->read()) { - $this->lastIncomingTimestamp = \microtime(true); - EventLoop::queue($this->handlePacket(...), $socket, $payload); - } - Logger::log("Exiting VoIP read loop in $this!"); - }); - } - }); - return $this; } /** * Handle incoming packet. @@ -356,28 +412,32 @@ final class VoIP { switch ($packet['_']) { case self::PKT_INIT: - //$this->voip_state = self::STATE_WAIT_INIT_ACK; - $this->peerVersion = $packet['protocol']; - $socket->write( - $this->encryptPacket([ - '_' => self::PKT_INIT_ACK, - 'protocol' => self::PROTOCOL_VERSION, - 'min_protocol' => self::MIN_PROTOCOL_VERSION, - 'all_streams' => [ - ['id' => 0, 'type' => self::STREAM_TYPE_AUDIO, 'codec' => self::CODEC_OPUS, 'frame_duration' => 60, 'enabled' => 1] - ] - ]) - ); + //$this->voipState = VoIPState::WAIT_INIT_ACK; + $socket->write($this->messageHandler->encryptPacket([ + '_' => self::PKT_INIT_ACK, + 'protocol' => self::PROTOCOL_VERSION, + 'min_protocol' => self::MIN_PROTOCOL_VERSION, + 'all_streams' => [ + ['id' => 0, 'type' => self::STREAM_TYPE_AUDIO, 'codec' => self::CODEC_OPUS, 'frame_duration' => 60, 'enabled' => 1] + ] + ])); + $socket->write($this->messageHandler->encryptPacket([ + '_' => self::PKT_INIT, + 'protocol' => self::PROTOCOL_VERSION, + 'min_protocol' => self::MIN_PROTOCOL_VERSION, + 'audio_streams' => [self::CODEC_OPUS], + 'video_streams' => [] + ])); + break; - // no break case self::PKT_INIT_ACK: if (!$this->bestEndpoint) { $this->bestEndpoint = $socket; $this->pingWatcher = EventLoop::delay(1.0, function (): void { $this->pendingPing = true; foreach ($this->sockets as $socket) { - //$socket->udpPing(); - $packet = $this->encryptPacket(['_' => self::PKT_PING]); + $socket->udpPing(); + $packet = $this->messageHandler->encryptPacket(['_' => self::PKT_PING]); EventLoop::queue(fn () => $socket->write($packet)); } }); @@ -393,6 +453,9 @@ final class VoIP case self::PKT_STREAM_DATA_X3: $cnt = 3; break; + case self::PKT_PING: + $socket->write($this->messageHandler->encryptPacket(['_' => self::PKT_PONG, 'out_seq_no' => $packet['out_seq_no']])); + break; case self::PKT_PONG: if ($this->pendingPing) { $this->pendingPing = false; @@ -402,16 +465,31 @@ final class VoIP } } break; - default: - \var_dump($packet); } } + private function startReadLoop(): void + { + foreach ($this->sockets as $socket) { + EventLoop::queue(function () use ($socket): void { + while ($payload = $socket->read()) { + $this->lastIncomingTimestamp = \microtime(true); + EventLoop::queue($this->handlePacket(...), $socket, $payload); + } + Logger::log("Exiting VoIP read loop in $this!"); + }); + } + $this->timeoutWatcher = EventLoop::repeat(10, function (): void { + if (\microtime(true) - $this->lastIncomingTimestamp > 10) { + $this->discard(['_' => 'phoneCallDiscardReasonDisconnect']); + } + }); + } /** * Start write loop. */ private function startWriteLoop(): void { - $this->voip_state = self::STATE_ESTABLISHED; + $this->voipState = VoIPState::ESTABLISHED; Logger::log("Call established, sending OPUS data!"); $this->tempHoldFiles = []; @@ -432,7 +510,7 @@ final class VoIP } $t = \microtime(true) + 0.060; while (!$this->packetQueue->isEmpty()) { - $packet = $this->encryptPacket(['_' => self::PKT_STREAM_DATA, 'stream_id' => 0, 'data' => $this->packetQueue->dequeue(), 'timestamp' => $this->timestamp]); + $packet = $this->messageHandler->encryptPacket(['_' => self::PKT_STREAM_DATA, 'stream_id' => 0, 'data' => $this->packetQueue->dequeue(), 'timestamp' => $this->opusTimestamp]); //Logger::log("Writing {$this->timestamp} in $this!"); $diff = $t - \microtime(true); @@ -442,7 +520,7 @@ final class VoIP $this->bestEndpoint->write($packet); $t += 0.060; - $this->timestamp += 60; + $this->opusTimestamp += 60; } } } @@ -506,125 +584,19 @@ final class VoIP return $this; } - /** - * Set MadelineProto instance. - */ - public function setMadeline(MTProto $MadelineProto): void - { - $this->MadelineProto = $MadelineProto; - } - - /** - * Get call protocol. - */ - public function getProtocol(): array - { - return $this->protocol; - } - - /** - * Get ID of other user. - */ - public function getOtherID(): int - { - return $this->otherID; - } - - /** - * Get call ID. - * - */ - public function getCallID(): string|int - { - return $this->callID; - } - - /** - * Get creation date. - * - */ - public function whenCreated(): int|bool - { - return $this->internalStorage['created'] ?? false; - } - - /** - * Parse config. - */ - public function parseConfig(): void - { - } - /** * Get call state. */ - public function getCallState(): int + public function getCallState(): CallState { - return $this->callState ?? self::CALL_STATE_ENDED; + return $this->callState; } - /** - * Get library version. + * Get VoIP state. */ - public function getVersion(): string + public function getVoIPState(): VoIPState { - return 'libponyvoip-1.0'; - } - - /** - * Get preferred relay ID. - */ - public function getPreferredRelayID(): int - { - return 0; - } - - /** - * Get last error. - */ - public function getLastError(): string - { - return ''; - } - - /** - * Get debug log. - */ - public function getDebugLog(): string - { - return ''; - } - - /** - * Get signal bar count. - */ - public function getSignalBarsCount(): int - { - return $this->signal; - } - - /** - * Get the value of creator. - */ - public function isCreator(): bool - { - return $this->creator; - } - - /** - * Get the value of authKey. - */ - public function getAuthKey(): PermAuthKey - { - return $this->authKey; - } - - /** - * Get the value of peerVersion. - */ - public function getPeerVersion(): int - { - return $this->peerVersion; + return $this->voipState; } /** @@ -632,7 +604,6 @@ final class VoIP */ public function __toString(): string { - $id = $this->callID['id']; - return "call {$id} with {$this->otherID}"; + return "call {$this->callID} with {$this->otherID}"; } } diff --git a/src/VoIP/AckHandler.php b/src/VoIP/AckHandler.php deleted file mode 100644 index 751971500..000000000 --- a/src/VoIP/AckHandler.php +++ /dev/null @@ -1,80 +0,0 @@ -. -*/ - -namespace danog\MadelineProto\VoIP; - -use danog\MadelineProto\Logger; - -/** - * @internal - */ -trait AckHandler -{ - private function seqgt(int $s1, int $s2): bool - { - return $s1 > $s2; - } - public function received_packet(int $last_ack_id, int $packet_seq_no, int $ack_mask): bool - { - if ($this->seqgt($packet_seq_no, $this->session_in_seq_no)) { - $diff = $packet_seq_no - $this->session_in_seq_no; - if ($diff > 31) { - $this->received_timestamp_map = \array_fill(0, 32, 0); - } else { - $remaining = 32-$diff; - for ($x = 0; $x < $remaining; $x++) { - $this->received_timestamp_map[$diff+$x] = $this->received_timestamp_map[$x]; - } - for ($x = 1; $x < $diff; $x++) { - $this->received_timestamp_map[$x] = 0; - } - $this->received_timestamp_map[0] = \microtime(true); - } - $this->session_in_seq_no = $packet_seq_no; - } elseif (($diff = $this->session_in_seq_no - $packet_seq_no) < 32) { - if (!$this->received_timestamp_map[$diff]) { - Logger::log("Got duplicate $packet_seq_no"); - return false; - } - $this->received_timestamp_map[$diff] = \microtime(true); - } else { - Logger::log("Packet $packet_seq_no is out of order and too late"); - return false; - } - if ($this->seqgt($last_ack_id, $this->session_out_seq_no)) { - $diff = $last_ack_id - $this->session_out_seq_no; - if ($diff > 31) { - $this->remote_ack_timestamp_map = \array_fill(0, 32, 0); - } else { - $remaining = 32-$diff; - for ($x = 0; $x < $remaining; $x++) { - $this->remote_ack_timestamp_map[$diff+$x] = $this->remote_ack_timestamp_map[$x]; - } - for ($x = 1; $x < $diff; $x++) { - $this->remote_ack_timestamp_map[$x] = 0; - } - $this->remote_ack_timestamp_map[0] = \microtime(true); - } - $this->session_out_seq_no = $last_ack_id; - - for ($x = 1; $x < 32; $x++) { - if (!$this->remote_ack_timestamp_map[$x] && ($ack_mask >> 32-$x) & 1) { - $this->remote_ack_timestamp_map[$x] = \microtime(true); - } - } - } - return true; - } -} diff --git a/src/VoIP/AuthKeyHandler.php b/src/VoIP/AuthKeyHandler.php index cd0c02466..75332b43e 100644 --- a/src/VoIP/AuthKeyHandler.php +++ b/src/VoIP/AuthKeyHandler.php @@ -20,6 +20,7 @@ declare(strict_types=1); namespace danog\MadelineProto\VoIP; +use Amp\DeferredFuture; use danog\MadelineProto\Exception; use danog\MadelineProto\Lang; use danog\MadelineProto\Logger; @@ -31,6 +32,7 @@ use danog\MadelineProto\RPCErrorException; use danog\MadelineProto\SecurityException; use danog\MadelineProto\VoIP; use phpseclib3\Math\BigInteger; +use Throwable; use const STR_PAD_LEFT; @@ -44,35 +46,9 @@ use const STR_PAD_LEFT; */ trait AuthKeyHandler { + /** @var array */ private array $calls = []; - /** - * Accept call from VoIP instance. - * - * @param VoIP $instance Call instance - * @param array $user User - * @internal - */ - public function acceptCallFrom(VoIP $instance, array $user): ?VoIP - { - if (!$this->acceptCall($user)) { - $instance->discard(); - return null; - } - return $instance; - } - /** - * @param VoIP $instance Call instance - * @param array $call Call info - * @param array $reason Discard reason - * @param array $rating Rating - * @param boolean $need_debug Needs debug? - * @internal - */ - public function discardCallFrom(VoIP $instance, array $call, array $reason, array $rating = [], bool $need_debug = true): VoIP - { - $this->discardCall($call, $reason, $rating, $need_debug); - return $instance; - } + private array $pendingCalls = []; /** * Request VoIP call. * @@ -80,228 +56,52 @@ trait AuthKeyHandler */ public function requestCall(mixed $user) { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - throw Exception::extension('libtgvoip'); - } $user = ($this->getInfo($user)); if (!isset($user['InputUser']) || $user['InputUser']['_'] === 'inputUserSelf') { throw new PeerNotInDbException(); } - $user = $user['InputUser']; - $this->logger->logger(\sprintf('Calling %s...', $user['user_id']), Logger::VERBOSE); - $dh_config = ($this->getDhConfig()); - $this->logger->logger('Generating a...', Logger::VERBOSE); - $a = BigInteger::randomRange(Magic::$two, $dh_config['p']->subtract(Magic::$two)); - $this->logger->logger('Generating g_a...', Logger::VERBOSE); - $g_a = $dh_config['g']->powMod($a, $dh_config['p']); - Crypt::checkG($g_a, $dh_config['p']); - $controller = new VoIP(true, $user['user_id'], $this, VoIP::CALL_STATE_REQUESTED); - $controller->storage = ['a' => $a, 'g_a' => \str_pad($g_a->toBytes(), 256, \chr(0), STR_PAD_LEFT)]; - $res = $this->methodCallAsyncRead('phone.requestCall', ['user_id' => $user, 'g_a_hash' => \hash('sha256', $g_a->toBytes(), true), 'protocol' => ['_' => 'phoneCallProtocol', 'udp_p2p' => true, 'udp_reflector' => true, 'min_layer' => 65, 'max_layer' => VoIP::getConnectionMaxLayer()]]); - $controller->setCall($res['phone_call']); - $this->calls[$res['phone_call']['id']] = $controller; - $this->updaters[UpdateLoop::GENERIC]->resume(); - return $controller; - } - /** - * Accept call. - * - * @param array $call Call - */ - public function acceptCall(array $call): bool - { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - throw new Exception(); + $user = $user['bot_api_id']; + if (isset($this->pendingCalls[$user])) { + return $this->pendingCalls[$user]->await(); } - if ($this->callStatus($call['id']) !== VoIP::CALL_STATE_ACCEPTED) { - $this->logger->logger(\sprintf(Lang::$current_lang['call_error_1'], $call['id'])); - return false; - } - $this->logger->logger(\sprintf(Lang::$current_lang['accepting_call'], $this->calls[$call['id']]->getOtherID()), Logger::VERBOSE); - $dh_config = ($this->getDhConfig()); - $this->logger->logger('Generating b...', Logger::VERBOSE); - $b = BigInteger::randomRange(Magic::$two, $dh_config['p']->subtract(Magic::$two)); - $g_b = $dh_config['g']->powMod($b, $dh_config['p']); - Crypt::checkG($g_b, $dh_config['p']); + $deferred = new DeferredFuture; + $this->pendingCalls[$user] = $deferred->getFuture(); + try { - $res = $this->methodCallAsyncRead('phone.acceptCall', ['peer' => ['id' => $call['id'], 'access_hash' => $call['access_hash'], '_' => 'inputPhoneCall'], 'g_b' => $g_b->toBytes(), 'protocol' => ['_' => 'phoneCallProtocol', 'udp_reflector' => true, 'udp_p2p' => true, 'min_layer' => 65, 'max_layer' => VoIP::getConnectionMaxLayer()]]); - } catch (RPCErrorException $e) { - if ($e->rpc === 'CALL_ALREADY_ACCEPTED') { - $this->logger->logger(\sprintf(Lang::$current_lang['call_already_accepted'], $call['id'])); - return true; - } - if ($e->rpc === 'CALL_ALREADY_DECLINED') { - $this->logger->logger(Lang::$current_lang['call_already_declined']); - $this->discardCall($call['id'], ['_' => 'phoneCallDiscardReasonHangup']); - return false; - } - throw $e; + $this->logger->logger(\sprintf('Calling %s...', $user), Logger::VERBOSE); + $dh_config = ($this->getDhConfig()); + $this->logger->logger('Generating a...', Logger::VERBOSE); + $a = BigInteger::randomRange(Magic::$two, $dh_config['p']->subtract(Magic::$two)); + $this->logger->logger('Generating g_a...', Logger::VERBOSE); + $g_a = $dh_config['g']->powMod($a, $dh_config['p']); + Crypt::checkG($g_a, $dh_config['p']); + $res = $this->methodCallAsyncRead('phone.requestCall', ['user_id' => $user, 'g_a_hash' => \hash('sha256', $g_a->toBytes(), true), 'protocol' => ['_' => 'phoneCallProtocol', 'udp_p2p' => true, 'udp_reflector' => true, 'min_layer' => 65, 'max_layer' => 92]])['phone_call']; + $res['a'] = $a; + $res['g_a'] = \str_pad($g_a->toBytes(), 256, \chr(0), STR_PAD_LEFT); + $this->calls[$res['id']] = $controller = new VoIP($this, $res); + unset($this->pendingCalls[$user]); + $deferred->complete($controller); + } catch (Throwable $e) { + unset($this->pendingCalls[$user]); + $deferred->error($e); } - $this->calls[$res['phone_call']['id']]->storage['b'] = $b; - $this->updaters[UpdateLoop::GENERIC]->resume(); - return true; - } - /** - * Confirm call. - * - * @param array $params Params - */ - public function confirmCall(array $params) - { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - throw Exception::extension('libtgvoip'); - } - if ($this->callStatus($params['id']) !== VoIP::CALL_STATE_REQUESTED) { - $this->logger->logger(\sprintf(Lang::$current_lang['call_error_2'], $params['id'])); - return false; - } - $this->logger->logger(\sprintf(Lang::$current_lang['call_confirming'], $this->calls[$params['id']]->getOtherID()), Logger::VERBOSE); - $dh_config = ($this->getDhConfig()); - $params['g_b'] = new BigInteger((string) $params['g_b'], 256); - Crypt::checkG($params['g_b'], $dh_config['p']); - $key = \str_pad($params['g_b']->powMod($this->calls[$params['id']]->storage['a'], $dh_config['p'])->toBytes(), 256, \chr(0), STR_PAD_LEFT); - try { - $res = ($this->methodCallAsyncRead('phone.confirmCall', ['key_fingerprint' => \substr(\sha1($key, true), -8), 'peer' => ['id' => $params['id'], 'access_hash' => $params['access_hash'], '_' => 'inputPhoneCall'], 'g_a' => $this->calls[$params['id']]->storage['g_a'], 'protocol' => ['_' => 'phoneCallProtocol', 'udp_reflector' => true, 'min_layer' => 65, 'max_layer' => VoIP::getConnectionMaxLayer()]]))['phone_call']; - } catch (RPCErrorException $e) { - if ($e->rpc === 'CALL_ALREADY_ACCEPTED') { - $this->logger->logger(\sprintf(Lang::$current_lang['call_already_accepted'], $params['id'])); - return true; - } - if ($e->rpc === 'CALL_ALREADY_DECLINED') { - $this->logger->logger(Lang::$current_lang['call_already_declined']); - $this->discardCall($params['id'], ['_' => 'phoneCallDiscardReasonHangup']); - return false; - } - throw $e; - } - $this->calls[$params['id']]->setCall($res); - $visualization = []; - $length = new BigInteger(\count(Magic::$emojis)); - foreach (\str_split(\hash('sha256', $key.\str_pad($this->calls[$params['id']]->storage['g_a'], 256, \chr(0), STR_PAD_LEFT), true), 8) as $number) { - $number[0] = \chr(\ord($number[0]) & 0x7f); - $visualization[] = Magic::$emojis[(int) (new BigInteger($number, 256))->divide($length)[1]->toString()]; - } - $this->calls[$params['id']]->setVisualization($visualization); - $this->calls[$params['id']]->configuration['endpoints'] = \array_merge($res['connections'], $this->calls[$params['id']]->configuration['endpoints']); - $this->calls[$params['id']]->configuration = \array_merge(['recv_timeout' => $this->config['call_receive_timeout_ms'] / 1000, 'init_timeout' => $this->config['call_connect_timeout_ms'] / 1000, 'data_saving' => VoIP::DATA_SAVING_NEVER, 'enable_NS' => true, 'enable_AEC' => true, 'enable_AGC' => true, 'auth_key' => $key, 'auth_key_id' => \substr(\sha1($key, true), -8), 'call_id' => \substr(\hash('sha256', $key, true), -16), 'network_type' => VoIP::NET_TYPE_ETHERNET], $this->calls[$params['id']]->configuration); - $this->calls[$params['id']]->parseConfig(); - return $this->calls[$params['id']]->startTheMagic(); - } - /** - * Complete call handshake. - * - * @param array $params Params - */ - public function completeCall(array $params) - { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - throw Exception::extension('libtgvoip'); - } - if ($this->callStatus($params['id']) !== VoIP::CALL_STATE_ACCEPTED || !isset($this->calls[$params['id']]->storage['b'])) { - $this->logger->logger(\sprintf(Lang::$current_lang['call_error_3'], $params['id'])); - return false; - } - $this->logger->logger(\sprintf(Lang::$current_lang['call_completing'], $this->calls[$params['id']]->getOtherID()), Logger::VERBOSE); - $dh_config = ($this->getDhConfig()); - if (\hash('sha256', $params['g_a_or_b'], true) != $this->calls[$params['id']]->storage['g_a_hash']) { - throw new SecurityException('Invalid g_a!'); - } - $params['g_a_or_b'] = new BigInteger((string) $params['g_a_or_b'], 256); - Crypt::checkG($params['g_a_or_b'], $dh_config['p']); - $key = \str_pad($params['g_a_or_b']->powMod($this->calls[$params['id']]->storage['b'], $dh_config['p'])->toBytes(), 256, \chr(0), STR_PAD_LEFT); - if (\substr(\sha1($key, true), -8) != $params['key_fingerprint']) { - throw new SecurityException(Lang::$current_lang['fingerprint_invalid']); - } - $visualization = []; - $length = new BigInteger(\count(Magic::$emojis)); - foreach (\str_split(\hash('sha256', $key.\str_pad($params['g_a_or_b']->toBytes(), 256, \chr(0), STR_PAD_LEFT), true), 8) as $number) { - $number[0] = \chr(\ord($number[0]) & 0x7f); - $visualization[] = Magic::$emojis[(int) (new BigInteger($number, 256))->divide($length)[1]->toString()]; - } - $this->calls[$params['id']]->setVisualization($visualization); - $this->calls[$params['id']]->configuration['endpoints'] = \array_merge($params['connections'], $this->calls[$params['id']]->configuration['endpoints']); - $this->calls[$params['id']]->configuration = \array_merge(['recv_timeout' => $this->config['call_receive_timeout_ms'] / 1000, 'init_timeout' => $this->config['call_connect_timeout_ms'] / 1000, 'data_saving' => VoIP::DATA_SAVING_NEVER, 'enable_NS' => true, 'enable_AEC' => true, 'enable_AGC' => true, 'auth_key' => $key, 'auth_key_id' => \substr(\sha1($key, true), -8), 'call_id' => \substr(\hash('sha256', $key, true), -16), 'network_type' => VoIP::NET_TYPE_ETHERNET], $this->calls[$params['id']]->configuration); - $this->calls[$params['id']]->parseConfig(); - return $this->calls[$params['id']]->startTheMagic(); + return $deferred->getFuture()->await(); } /** * Get call status. * * @param int $id Call ID */ - public function callStatus(int $id): int + public function callStatus(int $id): ?CallState { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - throw Exception::extension('libtgvoip'); - } if (isset($this->calls[$id])) { return $this->calls[$id]->getCallState(); } - return VoIP::CALL_STATE_NONE; + return null; } - /** - * Get call info. - * - * @param int $call Call ID - */ - public function getCall(int $call): array - { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - throw Exception::extension('libtgvoip'); - } - return $this->calls[$call]; - } - /** - * Discard call. - * - * @param array $call Call - * @param array $rating Rating - * @param boolean $need_debug Need debug? - */ - public function discardCall(array $call, array $reason, array $rating = [], bool $need_debug = true): ?VoIP - { - if (!\class_exists('\\danog\\MadelineProto\\VoIP')) { - throw Exception::extension('libtgvoip'); - } - if (!isset($this->calls[$call['id']])) { - return null; - } - $this->logger->logger(\sprintf(Lang::$current_lang['call_discarding'], $call['id']), Logger::VERBOSE); - try { - $res = $this->methodCallAsyncRead('phone.discardCall', ['peer' => $call, 'duration' => \time() - $this->calls[$call['id']]->whenCreated(), 'connection_id' => $this->calls[$call['id']]->getPreferredRelayID(), 'reason' => $reason]); - } catch (RPCErrorException $e) { - if (!\in_array($e->rpc, ['CALL_ALREADY_DECLINED', 'CALL_ALREADY_ACCEPTED'], true)) { - throw $e; - } - } - if (!empty($rating)) { - $this->logger->logger(\sprintf('Setting rating for call %s...', $call['id']), Logger::VERBOSE); - $this->methodCallAsyncRead('phone.setCallRating', ['peer' => $call, 'rating' => $rating['rating'], 'comment' => $rating['comment']]); - } - if ($need_debug && isset($this->calls[$call['id']])) { - $this->logger->logger(\sprintf('Saving debug data for call %s...', $call['id']), Logger::VERBOSE); - $this->methodCallAsyncRead('phone.saveCallDebug', ['peer' => $call, 'debug' => $this->calls[$call['id']]->getDebugLog()]); - } - if (!isset($this->calls[$call['id']])) { - return null; - } - $c = $this->calls[$call['id']]; - unset($this->calls[$call['id']]); - return $c; - } - /** - * Check state of calls. - * - * @internal - */ - public function checkCalls(): void - { - \array_walk($this->calls, function ($controller, $id): void { - if ($controller->getCallState() === VoIP::CALL_STATE_ENDED) { - $this->logger('Discarding ended call...'); - $controller->discard(); - unset($this->calls[$id]); - } - }); + + /** @internal */ + public function cleanupCall(int $id): void { + unset($this->calls[$id]); } } diff --git a/src/VoIP/CallState.php b/src/VoIP/CallState.php new file mode 100644 index 000000000..17a263fe5 --- /dev/null +++ b/src/VoIP/CallState.php @@ -0,0 +1,37 @@ +. + * + * @author Daniil Gentili + * @copyright 2016-2023 Daniil Gentili + * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 + * @link https://docs.madelineproto.xyz MadelineProto documentation + */ + +namespace danog\MadelineProto\VoIP; + + +enum CallState { + /** The call was requested */ + case REQUESTED; + /** An incoming call */ + case INCOMING; + /** The call was accepted */ + case ACCEPTED; + /** The call was confirmed */ + case CONFIRMED; + /** The call is ongoing */ + case RUNNING; + /** The call has ended */ + case ENDED; +} \ No newline at end of file diff --git a/src/VoIP/Endpoint.php b/src/VoIP/Endpoint.php index 37a1ff8dc..9c8392947 100644 --- a/src/VoIP/Endpoint.php +++ b/src/VoIP/Endpoint.php @@ -5,67 +5,48 @@ declare(strict_types=1); namespace danog\MadelineProto\VoIP; use Amp\Socket\Socket; +use danog\MadelineProto\Lang; use danog\MadelineProto\Logger; use danog\MadelineProto\MTProto\PermAuthKey; use danog\MadelineProto\MTProtoTools\Crypt; use danog\MadelineProto\Tools; use danog\MadelineProto\VoIP; +use Exception; use function Amp\Socket\connect; final class Endpoint { - /** - * IP address. - */ - private string $ip; - /** - * Port. - */ - private int $port; - /** - * Peer tag. - */ - private string $peerTag; - - /** - * Whether we're a reflector. - */ - private bool $reflector; - - /** - * Call instance. - */ - private VoIP $instance; /** * The socket. */ private ?Socket $socket = null; - - /** - * Whether we're the creator. - */ - private bool $creator; - - /** - * The auth key. - */ - private PermAuthKey $authKey; - /** * Create endpoint. */ - public function __construct(string $ip, int $port, string $peerTag, bool $reflector, VoIP $instance) + public function __construct( + private readonly string $ip, + private readonly int $port, + private readonly string $peerTag, + private readonly bool $reflector, + private readonly bool $creator, + private readonly string $authKey, + private readonly string $callID, + private readonly MessageHandler $handler + ) { - $this->ip = $ip; - $this->port = $port; - $this->peerTag = $peerTag; - $this->reflector = $reflector; - $this->instance = $instance; - $this->creator = $instance->isCreator(); - $this->authKey = $instance->getAuthKey(); $this->socket = connect("udp://{$this->ip}:{$this->port}"); } + public function __wakeup() + { + $this->socket = connect("udp://{$this->ip}:{$this->port}"); + } + public function __sleep(): array + { + $vars = get_object_vars($this); + unset($vars['socket']); + return $vars; + } public function __toString(): string { @@ -109,21 +90,21 @@ final class Endpoint */ public function read(): ?array { - $packet = $this->socket->read(); - if ($packet === null) { - return null; - } - do { + $packet = $this->socket->read(); + if ($packet === null) { + return null; + } + $payload = \fopen('php://memory', 'rw+b'); \fwrite($payload, $packet); \fseek($payload, 0); $pos = 0; - if ($this->instance->getPeerVersion() < 9 || $this->reflector) { - /*if (\fread($payload, 16) !== $this->peerTag) { + if ($this->handler->peerVersion < 9 || $this->reflector) { + if (\fread($payload, 16) !== $this->peerTag) { Logger::log('Received packet has wrong peer tag', Logger::ERROR); continue; - }*/ + } $pos = 16; } if (\fread($payload, 12) === "\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF\xFF") { @@ -149,11 +130,11 @@ final class Endpoint } else { fseek($payload, $pos); $message_key = \fread($payload, 16); - [$aes_key, $aes_iv] = Crypt::aesCalculate($message_key, $this->authKey->getAuthKey(), !$this->creator); + [$aes_key, $aes_iv] = Crypt::aesCalculate($message_key, $this->authKey, !$this->creator); $encrypted_data = \stream_get_contents($payload); $packet = Crypt::igeDecrypt($encrypted_data, $aes_key, $aes_iv); - if ($message_key != \substr(\hash('sha256', \substr($this->authKey->getAuthKey(), 88 + ($this->creator ? 8 : 0), 32).$packet, true), 8, 16)) { + if ($message_key != \substr(\hash('sha256', \substr($this->authKey, 88 + ($this->creator ? 8 : 0), 32).$packet, true), 8, 16)) { Logger::log('msg_key mismatch!', Logger::ERROR); continue; } @@ -177,7 +158,7 @@ final class Endpoint $flags = \unpack('V', \stream_get_contents($payload, 4))[1]; $result['_'] = $flags >> 24; if ($flags & 4) { - if (\stream_get_contents($payload, 16) !== $this->instance->configuration['call_id']) { + if (\stream_get_contents($payload, 16) !== $this->callID) { Logger::log('Call ID mismatch', Logger::ERROR); continue 2; } @@ -220,8 +201,8 @@ final class Endpoint break; default: - if ($this->instance->getPeerVersion() >= 8 || (!$this->instance->getPeerVersion())) { - \fseek($payload, -4, SEEK_CUR); + if ($this->handler->peerVersion >= 8 || (!$this->handler->peerVersion)) { + \fseek($payload, 0); $result['_'] = \ord(\stream_get_contents($payload, 1)); $in_seq_no = \unpack('V', \stream_get_contents($payload, 4))[1]; $out_seq_no = \unpack('V', \stream_get_contents($payload, 4))[1]; @@ -244,8 +225,8 @@ final class Endpoint continue 2; } } - if (!$this->instance->received_packet($in_seq_no, $out_seq_no, $ack_mask)) { - return $this->read(); + if (!$this->handler->shouldSkip($in_seq_no, $out_seq_no, $ack_mask)) { + continue; } switch ($result['_']) { // streamTypeSimple codec:int8 = StreamType; @@ -261,6 +242,7 @@ final class Endpoint for ($x = 0; $x < $length; $x++) { $result['audio_streams'][$x] = \stream_get_contents($message, 4); } + $this->handler->peerVersion = $result['protocol']; break; // streamType id:int8 type:int8 codec:int8 frame_duration:int16 enabled:int8 = StreamType; // @@ -296,10 +278,11 @@ final class Endpoint $result['timestamp'] = \unpack('V', \stream_get_contents($message, 4))[1]; $result['data'] = \stream_get_contents($message, $length); break; - /*case \danog\MadelineProto\VoIP::PKT_UPDATE_STREAMS: - break; - case \danog\MadelineProto\VoIP::PKT_PING: - break;*/ + case \danog\MadelineProto\VoIP::PKT_UPDATE_STREAMS: + continue 2; + case \danog\MadelineProto\VoIP::PKT_PING: + $result['out_seq_no'] = $out_seq_no; + break; case VoIP::PKT_PONG: if (\fstat($payload)['size'] - \ftell($payload)) { $result['out_seq_no'] = \unpack('V', \stream_get_contents($payload, 4))[1]; @@ -365,11 +348,11 @@ final class Endpoint $padding += 16; } $plaintext .= Tools::random($padding); - $message_key = \substr(\hash('sha256', \substr($this->authKey->getAuthKey(), 88 + ($this->creator ? 0 : 8), 32).$plaintext, true), 8, 16); - [$aes_key, $aes_iv] = Crypt::aesCalculate($message_key, $this->authKey->getAuthKey(), $this->creator); + $message_key = \substr(\hash('sha256', \substr($this->authKey, 88 + ($this->creator ? 0 : 8), 32).$plaintext, true), 8, 16); + [$aes_key, $aes_iv] = Crypt::aesCalculate($message_key, $this->authKey, $this->creator); $payload = $message_key.Crypt::igeEncrypt($plaintext, $aes_key, $aes_iv); - if ($this->instance->getPeerVersion() < 9 || $this->reflector) { + if ($this->handler->peerVersion < 9 || $this->reflector) { $payload = $this->peerTag.$payload; } @@ -384,11 +367,4 @@ final class Endpoint $this->socket->write($this->peerTag.Tools::packSignedLong(-1).Tools::packSignedInt(-1).Tools::packSignedInt(-2).Tools::random(8)); return true; } - /** - * Get peer tag. - */ - public function getPeerTag(): string - { - return $this->peerTag; - } } diff --git a/src/VoIP/MessageHandler.php b/src/VoIP/MessageHandler.php index 579d52d85..03cd0d3c8 100644 --- a/src/VoIP/MessageHandler.php +++ b/src/VoIP/MessageHandler.php @@ -15,6 +15,7 @@ If not, see . namespace danog\MadelineProto\VoIP; +use danog\MadelineProto\Logger; use danog\MadelineProto\Tools; use danog\MadelineProto\VoIP; @@ -23,8 +24,22 @@ use danog\MadelineProto\VoIP; * * @internal */ -trait MessageHandler +final class MessageHandler { + private array $received_timestamp_map = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + private array $remote_ack_timestamp_map = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + + private int $session_out_seq_no = 0; + private int $session_in_seq_no = 0; + + public int $peerVersion = 0; + + public function __construct( + private readonly VoIP $instance, + private readonly string $callID + ) + { + } private static function pack_string(string $object): string { $l = \strlen($object); @@ -42,7 +57,7 @@ trait MessageHandler return $concat; } - private function encryptPacket(array $args, bool $increase_seqno = true): string + public function encryptPacket(array $args, bool $init = false): string { $message = ''; switch ($args['_']) { @@ -167,11 +182,11 @@ trait MessageHandler if ($this->peerVersion >= 8 || (!$this->peerVersion)) { $payload = \chr($args['_']); $payload .= Tools::packUnsignedInt($this->session_in_seq_no); - $payload .= Tools::packUnsignedInt($this->session_out_seq_no); + $payload .= Tools::packUnsignedInt($init ? 0 : $this->session_out_seq_no); $payload .= Tools::packUnsignedInt($ack_mask); $payload .= \chr(0); $payload .= $message; - } elseif (\in_array($this->voip_state, [VoIP::STATE_WAIT_INIT, VoIP::STATE_WAIT_INIT_ACK], true)) { + } elseif (\in_array($this->instance->getVoIPState(), [VoIPState::WAIT_INIT, VoIPState::WAIT_INIT_ACK], true)) { $payload = VoIP::TLID_DECRYPTED_AUDIO_BLOCK; $payload .= Tools::random(8); $payload .= \chr(7); @@ -185,9 +200,9 @@ trait MessageHandler $flags = \strlen($message) ? $flags | 1 : $flags & ~1; // raw_data $flags = $flags | ($args['_'] << 24); $payload .= Tools::packUnsignedInt($flags); - $payload .= $this->configuration['call_id']; + $payload .= $this->callID; $payload .= Tools::packUnsignedInt($this->session_in_seq_no); - $payload .= Tools::packUnsignedInt($this->session_out_seq_no); + $payload .= Tools::packUnsignedInt($init ? 0 : $this->session_out_seq_no); $payload .= Tools::packUnsignedInt($ack_mask); $payload .= VoIP::PROTO_ID; if ($flags & 2) { @@ -201,14 +216,67 @@ trait MessageHandler $payload .= Tools::random(8); $payload .= \chr(7); $payload .= Tools::random(7); - $message = \chr($args['_']).Tools::packUnsignedInt($this->session_in_seq_no).Tools::packUnsignedInt($this->session_out_seq_no).Tools::packUnsignedInt($ack_mask).$message; + $message = \chr($args['_']).Tools::packUnsignedInt($this->session_in_seq_no).Tools::packUnsignedInt($init ? 0 : $this->session_out_seq_no).Tools::packUnsignedInt($ack_mask).$message; $payload .= $this->pack_string($message); } - if ($increase_seqno) { + if (!$init) { $this->session_out_seq_no++; } return $payload; } + + + public function shouldSkip(int $last_ack_id, int $packet_seq_no, int $ack_mask): bool + { + if ($packet_seq_no > $this->session_in_seq_no) { + $diff = $packet_seq_no - $this->session_in_seq_no; + if ($diff > 31) { + $this->received_timestamp_map = \array_fill(0, 32, 0); + } else { + $remaining = 32-$diff; + for ($x = 0; $x < $remaining; $x++) { + $this->received_timestamp_map[$diff+$x] = $this->received_timestamp_map[$x]; + } + for ($x = 1; $x < $diff; $x++) { + $this->received_timestamp_map[$x] = 0; + } + $this->received_timestamp_map[0] = \microtime(true); + } + $this->session_in_seq_no = $packet_seq_no; + } elseif (($diff = $this->session_in_seq_no - $packet_seq_no) < 32) { + if (!$this->received_timestamp_map[$diff]) { + Logger::log("Got duplicate $packet_seq_no"); + return false; + } + $this->received_timestamp_map[$diff] = \microtime(true); + } else { + Logger::log("Packet $packet_seq_no is out of order and too late"); + return false; + } + if ($last_ack_id > $this->session_out_seq_no) { + $diff = $last_ack_id - $this->session_out_seq_no; + if ($diff > 31) { + $this->remote_ack_timestamp_map = \array_fill(0, 32, 0); + } else { + $remaining = 32-$diff; + for ($x = 0; $x < $remaining; $x++) { + $this->remote_ack_timestamp_map[$diff+$x] = $this->remote_ack_timestamp_map[$x]; + } + for ($x = 1; $x < $diff; $x++) { + $this->remote_ack_timestamp_map[$x] = 0; + } + $this->remote_ack_timestamp_map[0] = \microtime(true); + } + $this->session_out_seq_no = $last_ack_id; + + for ($x = 1; $x < 32; $x++) { + if (!$this->remote_ack_timestamp_map[$x] && ($ack_mask >> 32-$x) & 1) { + $this->remote_ack_timestamp_map[$x] = \microtime(true); + } + } + } + return true; + } } diff --git a/src/VoIP/VoIPState.php b/src/VoIP/VoIPState.php new file mode 100644 index 000000000..4b415156a --- /dev/null +++ b/src/VoIP/VoIPState.php @@ -0,0 +1,33 @@ +. + * + * @author Daniil Gentili + * @copyright 2016-2023 Daniil Gentili + * @license https://opensource.org/licenses/AGPL-3.0 AGPLv3 + * @link https://docs.madelineproto.xyz MadelineProto documentation + */ + +namespace danog\MadelineProto\VoIP; + +/** + * VoIP protcol state. + */ +enum VoIPState { + case CREATED; + case WAIT_INIT; + case WAIT_INIT_ACK; + case ESTABLISHED; + case FAILED; + case RECONNECTING; +} \ No newline at end of file