diff --git a/src/FederationLib/Classes/Utilities.php b/src/FederationLib/Classes/Utilities.php index d861a07..52b1d20 100644 --- a/src/FederationLib/Classes/Utilities.php +++ b/src/FederationLib/Classes/Utilities.php @@ -6,6 +6,7 @@ use Exception; use FederationLib\Enums\SerializationMethod; + use FederationLib\Interfaces\PeerMetadataInterface; use FederationLib\Interfaces\SerializableObjectInterface; use InvalidArgumentException; use LogLib\Log; @@ -182,10 +183,14 @@ return $outliers; } - public static function weightedRandomPick( array $data): string + /** + * @param array $data + * @return string + */ + public static function weightedRandomPick(array $data): string { $totalWeight = array_sum($data); - if($totalWeight == 0) + if($totalWeight === 0) { throw new InvalidArgumentException('Total weight cannot be 0'); } @@ -210,5 +215,37 @@ return $item; } } + + // Select a random item if the cumulative weight is 0 + return array_rand($data); + } + /** + * This function returns an array showing the changed values comparing from $a to $b + * if $b contains changes that is different from $a, it will be returned in the array + * + * @param PeerMetadataInterface $a + * @param PeerMetadataInterface $b + * @return array + */ + public static function metadataDifferences(PeerMetadataInterface $a, PeerMetadataInterface $b, array $filter=[]): array + { + $differences = []; + $a_array = $a->toArray(); + $b_array = $b->toArray(); + + foreach ($a_array as $key => $value) + { + if(in_array($key, $filter, true)) + { + continue; + } + + if($value !== $b_array[$key]) + { + $differences[$key] = $b_array[$key]; + } + } + + return $differences; } } \ No newline at end of file diff --git a/src/FederationLib/Enums/Standard/ErrorCodes.php b/src/FederationLib/Enums/Standard/ErrorCodes.php index e48009e..b905ce3 100644 --- a/src/FederationLib/Enums/Standard/ErrorCodes.php +++ b/src/FederationLib/Enums/Standard/ErrorCodes.php @@ -47,13 +47,25 @@ public const CLIENT_DISABLED = 1004; + /** + * The given peer metadata is invalid or malformed. + */ + public const INVALID_PEER_METADATA = 2000; + + public const PEER_METADATA_NOT_FOUND = 2001; + + public const ALL = [ self::INTERNAL_SERVER_ERROR, self::ACCESS_DENIED, + self::CLIENT_NOT_FOUND, self::INVALID_CLIENT_NAME, self::INVALID_CLIENT_DESCRIPTION, self::SIGNATURE_VERIFICATION_FAILED, - self::CLIENT_DISABLED + self::CLIENT_DISABLED, + + self::INVALID_PEER_METADATA, + ]; } \ No newline at end of file diff --git a/src/FederationLib/Exceptions/Standard/InvalidPeerMetadataException.php b/src/FederationLib/Exceptions/Standard/InvalidPeerMetadataException.php new file mode 100644 index 0000000..4be98fb --- /dev/null +++ b/src/FederationLib/Exceptions/Standard/InvalidPeerMetadataException.php @@ -0,0 +1,19 @@ +getUuid(); + } + + $telegram_user_metadata->validate(); + $cache_key = sprintf('telegram_user_metadata:%s', $telegram_user_metadata->getFederatedAddress()); + $redis_client = null; + + if(Configuration::isCacheSystemEnabled() && Configuration::getObjectCacheEnabled('peer_objects')) + { + try + { + $redis_client = RedisConnectionManager::getConnectionFromConfig('peer_objects'); + + if($redis_client->exists($cache_key)) + { + $redis_client->del($cache_key); + } + } + catch(Exception $e) + { + Log::warning(Misc::FEDERATIONLIB, sprintf('There was an error with the cache system while tyring to sync peer metadata with the Redis server: %s', $e->getMessage()), $e); + } + } + + try + { + $old_metadata = $this->getMetadata($telegram_user_metadata->getFederatedAddress()); + } + catch(PeerMetadataNotFoundException $e) + { + unset($e); + + // If it doesn't exist, we create it instead of updating it + return $this->registerMetadata($client_uuid, $telegram_user_metadata); + } + + // Take the old metadata and get the differences + $differences = Utilities::metadataDifferences($old_metadata, $telegram_user_metadata); + + try + { + // If there are no differences, then we don't need to update the database + if(count($differences) === 0) + { + if($redis_client !== null && Configuration::getObjectCacheTtl('peer_objects') > 0) + { + // Reset the TTL since we just accessed it + $redis_client->expire($cache_key, Configuration::getObjectCacheTtl('peer_objects')); + } + + return $telegram_user_metadata->getFederatedAddress(); + } + + $qb = Database::getConnection()->createQueryBuilder(); + $qb->update(DatabaseTables::PEERS_TELEGRAM_USER); + $qb->where('federated_address = :federated_address'); + $qb->setParameter('federated_address', $telegram_user_metadata->getFederatedAddress()); + $qb->set('updated_timestamp', $qb->createNamedParameter(time(), ParameterType::INTEGER)); + $qb->set('updated_client', $qb->createNamedParameter($client_uuid)); + $qb->setMaxResults(1); + + foreach($differences as $key => $value) + { + $qb->set($key, $qb->createNamedParameter($value)); + } + + $qb->executeQuery(); + } + catch(Exception $e) + { + throw new DatabaseException(sprintf('There was a database error while trying to sync peer metadata: %s', $e->getMessage()), $e); + } + + if($redis_client !== null && count($differences) > 0) + { + try + { + foreach($differences as $key => $value) + { + $redis_client->hSet($cache_key, $key, $value); + } + } + catch(Exception $e) + { + Log::warning(Misc::FEDERATIONLIB, sprintf('There was an error with the cache system while tyring to sync peer metadata with the Redis server: %s', $e->getMessage()), $e); + } + } + + return $telegram_user_metadata->getFederatedAddress(); + } + + /** + * Registers the client's metadata with the database + * + * @param Client|string $client_uuid + * @param TelegramUserMetadata $telegram_user_metadata + * @return string + * @throws DatabaseException + */ + public function registerMetadata(Client|string $client_uuid, TelegramUserMetadata $telegram_user_metadata): string + { + if($client_uuid instanceof Client) + { + $client_uuid = $client_uuid->getUuid(); + } + + try + { + $telegram_user_metadata->validate(); + $qb = Database::getConnection()->createQueryBuilder(); + $qb->insert(DatabaseTables::PEERS_TELEGRAM_USER); + + $qb->setValue('federated_address', $qb->createNamedParameter($telegram_user_metadata->getFederatedAddress())); + $qb->setValue('updated_timestamp', $qb->createNamedParameter(time(), ParameterType::INTEGER)); + $qb->setValue('updated_client', $qb->createNamedParameter($client_uuid)); + + foreach($telegram_user_metadata->toArray() as $key => $value) + { + switch(gettype($value)) + { + case 'array': + $qb->setValue($key, $qb->createNamedParameter(implode(',', $value))); + break; + case 'NULL': + $qb->setValue($key, $qb->createNamedParameter(null, ParameterType::NULL)); + break; + case 'boolean': + $qb->setValue($key, $qb->createNamedParameter((int)$value, ParameterType::INTEGER)); + break; + case 'integer': + $qb->setValue($key, $qb->createNamedParameter($value, ParameterType::INTEGER)); + break; + + default: + $qb->setValue($key, $qb->createNamedParameter($value)); + break; + } + } + + $qb->executeQuery(); + } + catch(Exception $e) + { + throw new DatabaseException(sprintf('There was a database error while trying to register peer metadata: %s', $e->getMessage()), $e); + } + + return $telegram_user_metadata->getFederatedAddress(); + } + + /** + * @param Peer|string $federated_address + * @throws PeerMetadataNotFoundException + * @throws DatabaseException + * @return TelegramUserMetadata + */ + public function getMetadata(Peer|string $federated_address): TelegramUserMetadata + { + if($federated_address instanceof Peer) + { + $federated_address = $federated_address->getFederatedAddress(); + } + + if(Configuration::isCacheSystemEnabled() && Configuration::getObjectCacheEnabled('peer_objects')) + { + try + { + $redis = RedisConnectionManager::getConnectionFromConfig('peer_objects'); + $key = sprintf('telegram_user_metadata:%s', $federated_address); + + if($redis->exists($key)) + { + $telegram_user_metadata = TelegramUserMetadata::fromArray($redis->hGetAll($key), true); + + if(Configuration::getObjectCacheTTL('peer_objects') > 0) + { + $redis->expire($key, Configuration::getObjectCacheTTL('peer_objects')); + } + + Log::debug(Misc::FEDERATIONLIB, sprintf('Loaded peer metadata object %s from cache', $federated_address)); + return $telegram_user_metadata; + } + } + catch(Exception $e) + { + Log::warning(Misc::FEDERATIONLIB, sprintf('Failed to load peer metadata object %s from cache: %s', $federated_address, $e->getMessage())); + } + } + + $qb = Database::getConnection()->createQueryBuilder(); + $qb->select( + 'id', + 'type', + 'title', + 'username', + 'first_name', + 'last_name', + 'is_forum', + 'updated_timestamp', + 'updated_client' + ); + $qb->from(DatabaseTables::PEERS_TELEGRAM_USER); + $qb->where('federated_address = :federated_address'); + $qb->setParameter('federated_address', $federated_address); + $qb->setMaxResults(1); + + try + { + $result = $qb->executeQuery(); + + if($result->rowCount() === 0) + { + throw new PeerMetadataNotFoundException(sprintf('Peer metadata not found for federated address %s', $federated_address)); + } + + $telegram_user_metadata = TelegramUserMetadata::fromArray($result->fetchAssociative(), true); + } + catch(PeerMetadataNotFoundException $e) + { + throw $e; + } + catch(Exception $e) + { + throw new DatabaseException(sprintf('Failed to get peer metadata for federated address %s', $federated_address), $e); + } + + if(Configuration::isCacheSystemEnabled() && Configuration::getObjectCacheEnabled('peer_objects')) + { + try + { + $redis = RedisConnectionManager::getConnectionFromConfig('peer_objects'); + + $key = sprintf('telegram_user_metadata:%s', $federated_address); + $redis->hMSet($key, $telegram_user_metadata->toArray()); + + if(Configuration::getObjectCacheTTL('peer_objects') > 0) + { + $redis->expire($key, Configuration::getObjectCacheTTL('peer_objects')); + } + + Log::debug(Misc::FEDERATIONLIB, sprintf('Cached peer metadata object %s', $federated_address)); + } + catch(Exception $e) + { + Log::warning(Misc::FEDERATIONLIB, sprintf('Failed to cache peer metadata object %s: %s', $federated_address, $e->getMessage())); + } + } + + return $telegram_user_metadata; + } + } \ No newline at end of file diff --git a/src/FederationLib/Managers/PeerManager.php b/src/FederationLib/Managers/PeerManager.php index ea8af86..5860236 100644 --- a/src/FederationLib/Managers/PeerManager.php +++ b/src/FederationLib/Managers/PeerManager.php @@ -2,14 +2,22 @@ namespace FederationLib\Managers; + use FederationLib\Enums\Standard\UserPeerType; use FederationLib\FederationLib; + use FederationLib\Interfaces\PeerMetadataManagerInterface; + use FederationLib\Objects\Standard\PeerMetadata\TelegramUserMetadata; class PeerManager { /** * @var FederationLib */ - private FederationLib $federationLib; + private $federationLib; + + /** + * @var PeerMetadataManagerInterface[] + */ + private $metadata_managers; /** * PeerManager constructor. @@ -19,5 +27,6 @@ public function __construct(FederationLib $federationLib) { $this->federationLib = $federationLib; + $this->metadata_managers[UserPeerType::TELEGRAM_USER] = new TelegramUserMetadata(); } } \ No newline at end of file diff --git a/src/FederationLib/Managers/RedisConnectionManager.php b/src/FederationLib/Managers/RedisConnectionManager.php index 4b68169..48a9346 100644 --- a/src/FederationLib/Managers/RedisConnectionManager.php +++ b/src/FederationLib/Managers/RedisConnectionManager.php @@ -17,6 +17,19 @@ */ private static $connections; + /** + * @param string $name + * @return \Redis + * @throws CacheConnectionException + */ + public static function getConnectionFromConfig(string $name): \Redis + { + return self::getConnection( + Configuration::getObjectCacheServerPreference($name), + Configuration::getObjectCacheServerFallback($name) + ); + } + /** * Returns the requested redis connection to use * diff --git a/src/FederationLib/Objects/Standard/PeerMetadata/TelegramUserMetadata.php b/src/FederationLib/Objects/Standard/PeerMetadata/TelegramUserMetadata.php new file mode 100644 index 0000000..c37f446 --- /dev/null +++ b/src/FederationLib/Objects/Standard/PeerMetadata/TelegramUserMetadata.php @@ -0,0 +1,315 @@ +id === null) + { + throw new InvalidPeerMetadataException(sprintf('The property "id" is required in the metadata for peer type %s', UserPeerType::TELEGRAM_USER)); + } + + if(!is_int($this->id)) + { + throw new InvalidPeerMetadataException(sprintf('The property "id" must be an integer in metadata for peer type %s, got %s', UserPeerType::TELEGRAM_USER, gettype($this->id))); + } + + if(!is_bool($this->is_bot)) + { + throw new InvalidPeerMetadataException(sprintf('The property "is_bot" must be a boolean in metadata for peer type %s, got %s', UserPeerType::TELEGRAM_USER, gettype($this->is_bot))); + } + + if($this->first_name === null) + { + throw new InvalidPeerMetadataException(sprintf('The property "first_name" is required in the metadata for peer type %s', UserPeerType::TELEGRAM_USER)); + } + + if(!is_string($this->first_name)) + { + throw new InvalidPeerMetadataException(sprintf('The property "first_name" must be a string in metadata for peer type %s, got %s', UserPeerType::TELEGRAM_USER, gettype($this->first_name))); + } + + if($this->last_name !== null && !is_string($this->last_name)) + { + throw new InvalidPeerMetadataException(sprintf('The property "last_name" must be a string in metadata for peer type %s, got %s', UserPeerType::TELEGRAM_USER, gettype($this->last_name))); + } + + if($this->username !== null && !is_string($this->username)) + { + throw new InvalidPeerMetadataException(sprintf('The property "username" must be a string in metadata for peer type %s, got %s', UserPeerType::TELEGRAM_USER, gettype($this->username))); + } + + if($this->language_code !== null && !is_string($this->language_code)) + { + throw new InvalidPeerMetadataException(sprintf('The property "language_code" must be a string in metadata for peer type %s, got %s', UserPeerType::TELEGRAM_USER, gettype($this->language_code))); + } + + if(!is_bool($this->is_premium)) + { + throw new InvalidPeerMetadataException(sprintf('The property "is_premium" must be a boolean in metadata for peer type %s, got %s', UserPeerType::TELEGRAM_USER, gettype($this->is_premium))); + } + } + + /** + * Returns True if the peer is a bot, False otherwise + * + * @return bool + */ + public function isAutomated(): bool + { + return $this->is_bot; + } + + /** + * Returns the platform-specific ID of the peer + * + * @return string + */ + public function getPlatformId(): string + { + return $this->id; + } + + /** + * Returns the standard federated address of the peer + * Format: telegram.user: + * + * @return string + */ + public function getFederatedAddress(): string + { + return sprintf('%s:%s', UserPeerType::TELEGRAM_USER, $this->id); + } + + /** + * Sets the client UUID that last updated the record + * + * @param string|null $client + * @return void + */ + public function setUpdatedClient(?string $client): void + { + $this->updated_client = $client; + } + + /** + * Returns the Timestamp for when the record was last updated + * + * @return int + */ + public function getLastUpdatedTimestamp(): int + { + return $this->updated_timestamp; + } + + /** + * Sets the Timestamp for when the record was last updated + * + * @param int|null $timestamp + * @return void + */ + public function setLastUpdatedTimestamp(?int $timestamp): void + { + $this->updated_timestamp = $timestamp; + } + + /** + * Returns the Client UUID that last updated the record + * + * @return ?string + */ + public function getUpdatedClient(): ?string + { + return $this->updated_client; + } + + /** + * Unique identifier for this user or bot. This number may have more than 32 significant bits and some + * programming languages may have difficulty/silent defects in interpreting it. But it has at most 52 + * significant bits, so a 64-bit integer or double-precision float type are safe for storing this identifier. + * + * @return int + */ + public function getId(): int + { + return $this->id; + } + + /** + * True, if this user is a bot + * + * @return bool + */ + public function isBot(): bool + { + return $this->is_bot; + } + + /** + * User's or bot's first name + * + * @return string + */ + public function getFirstName(): string + { + return $this->first_name; + } + + /** + * Optional. User's or bot's last name + * + * @return string|null + */ + public function getLastName(): ?string + { + return $this->last_name; + } + + /** + * Optional. User's or bot's username + * + * @return string|null + */ + public function getUsername(): ?string + { + return $this->username; + } + + /** + * Optional. IETF language tag of the user's language + * + * @return string|null + */ + public function getLanguageCode(): ?string + { + return $this->language_code; + } + + /** + * True, if this user is a Telegram Premium user + * + * @return bool + */ + public function isPremium(): bool + { + return $this->is_premium; + } + + /** + * Returns an array representation of the object. + * + * @param bool $raw + * @return array + */ + public function toArray(bool $raw = false): array + { + $return = [ + 'id' => $this->id, + 'is_bot' => $this->is_bot, + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'username' => $this->username, + 'language_code' => $this->language_code, + 'is_premium' => $this->is_premium + ]; + + if(!$raw) + { + return $return; + } + + return array_merge($return, [ + 'updated_timestamp' => $this->updated_timestamp, + 'updated_client' => $this->updated_client + ]); + } + + /** + * Constructs object from an array representation. + * + * @param array $array + * @param bool $raw + * @return TelegramUserMetadata + */ + public static function fromArray(array $array, bool $raw = false): TelegramUserMetadata + { + $object = new self(); + + $object->id = $array['id']; + $object->is_bot = $array['is_bot'] ?? false; + $object->first_name = $array['first_name']; + $object->last_name = $array['last_name'] ?? null; + $object->username = $array['username'] ?? null; + $object->language_code = $array['language_code'] ?? null; + $object->is_premium = $array['is_premium'] ?? false; + + if(!$raw) + { + return $object; + } + + $object->updated_timestamp = $array['updated_timestamp'] ?? null; + $object->updated_client = $array['updated_client'] ?? null; + + return $object; + } + } \ No newline at end of file