diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 07465eb..dacf830 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -10,7 +10,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest ] - php: [ '8.1', '8.2', '8.3' ] + php: [ '8.4', '8.5' ] steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/setup_test.yml b/.github/workflows/setup_test.yml index 55199a7..a72340b 100644 --- a/.github/workflows/setup_test.yml +++ b/.github/workflows/setup_test.yml @@ -9,16 +9,13 @@ jobs: strategy: matrix: os: [ ubuntu-latest ] - php: [ '8.1', '8.2', '8.3' ] - laravel: [ 11.*, 10.*, 9.* ] + php: [ '8.4', '8.5' ] + laravel: [ 12.*, 11.* ] include: - - laravel: 11.* - testbench: 9.* - - laravel: 10.* - testbench: 8.* - exclude: - - laravel: 11.* - php: 8.1 + - laravel: 12.* + php: 8.4 + - laravel: 12.* + php: 8.5 steps: - name: Setup PHP uses: shivammathur/setup-php@v2 diff --git a/.scrutinizer.yml b/.scrutinizer.yml index a31c47b..cfd0325 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -7,7 +7,7 @@ build: override: [ php-scrutinizer-run ] environment: php: - version: 8.2 + version: 8.3 checks: php: diff --git a/common/helpers.php b/common/helpers.php index a1d19e2..1b337e0 100755 --- a/common/helpers.php +++ b/common/helpers.php @@ -10,7 +10,7 @@ * * @return string|null */ - function tgn_singularity(string $word): string|null + function tgn_singularity(string $word): ?string { static $singular_rules = [ '/(quiz)zes$/i' => '$1', @@ -56,7 +56,7 @@ function tgn_singularity(string $word): string|null */ function tgn_snake_case(string $string): string { - $string = preg_replace('/\s+/', '_', $string); + $string = preg_replace('/\s+/', '_', $string) ?? $string; return strtolower($string); } @@ -100,7 +100,7 @@ function tgn_convert_event_name(string $event): string */ function tgn_convert_action_name(string $action): string { - return strtolower(preg_replace('/(?getTemplateData( $partialPath, $data ); - return $content ?: null; + return $content !== '' ? $content : null; } } } if (!function_exists('tgn_view')) { /** - * Get view template + * Get view template from the correct source (Laravel Blade or standalone PHP). * - * @param string $partialPath - * @param array $data - * - * @noinspection PhpMissingReturnTypeInspection + * @param string $partialPath Dot-notation template path + * @param array $data Variables to pass to the template */ - function tgn_view(string $partialPath, array $data = []) + function tgn_view(string $partialPath, array $data = []): mixed { if (class_exists('Illuminate\Foundation\Application')) { $partialPath = config('telegram-git-notifier.view.namespace') . '::' . $partialPath; diff --git a/composer.json b/composer.json index 7e4e442..4a34c66 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "php": "^8.4|^8.5", "eleirbag89/telegrambotphp": "^1.4", "guzzlehttp/guzzle": "^7.8", + "psr/log": "^3.0", "symfony/http-foundation": "^7.0", "vlucas/phpdotenv": "^5.6" }, diff --git a/config/jsons/github-events.json b/config/jsons/github-events.json old mode 100644 new mode 100755 diff --git a/config/jsons/gitlab-events.json b/config/jsons/gitlab-events.json old mode 100644 new mode 100755 diff --git a/config/jsons/tgn-settings.json b/config/jsons/tgn-settings.json old mode 100644 new mode 100755 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5bba7cc..d291033 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -4,4 +4,3 @@ parameters: - src - config tmpDir: build/phpstan - checkMissingIterableValueType: false diff --git a/src/Bot.php b/src/Bot.php index cfd5f1b..55421d1 100644 --- a/src/Bot.php +++ b/src/Bot.php @@ -2,7 +2,7 @@ namespace CSlant\TelegramGitNotifier; -use CSlant\TelegramGitNotifier\Constants\EventConstant; +use CSlant\TelegramGitNotifier\Enums\Platform; use CSlant\TelegramGitNotifier\Exceptions\ConfigFileException; use CSlant\TelegramGitNotifier\Interfaces\BotInterface; use CSlant\TelegramGitNotifier\Models\Event; @@ -39,12 +39,12 @@ class Bot implements BotInterface * @throws ConfigFileException */ public function __construct( - Telegram $telegram = null, + ?Telegram $telegram = null, ?string $chatBotId = null, - Event $event = null, - ?string $platform = EventConstant::DEFAULT_PLATFORM, + ?Event $event = null, + ?string $platform = Platform::DEFAULT, ?string $platformFile = null, - Setting $setting = null, + ?Setting $setting = null, ?string $settingFile = null, ) { $this->event = $event ?? new Event(); @@ -61,7 +61,7 @@ public function __construct( public function validateSettingFile(): void { - if (empty($this->setting->getSettingFile())) { + if ($this->setting->getSettingFile() === '') { throw ConfigFileException::settingFile($this->setting->getSettingFile()); } } diff --git a/src/Constants/EventConstant.php b/src/Constants/EventConstant.php index e054688..19a3edc 100644 --- a/src/Constants/EventConstant.php +++ b/src/Constants/EventConstant.php @@ -4,25 +4,9 @@ final class EventConstant { - public const DEFAULT_PLATFORM = 'github'; - - public const WEBHOOK_EVENT_HEADER = [ - 'github' => 'HTTP_X_GITHUB_EVENT', - 'gitlab' => 'HTTP_X_GITLAB_EVENT', - ]; - public const EVENT_PREFIX = SettingConstant::SETTING_CUSTOM_EVENTS . '.evt.'; - public const GITHUB_EVENT_SEPARATOR = 'gh.'; - - public const GITLAB_EVENT_SEPARATOR = 'gl.'; - public const EVENT_HAS_ACTION_SEPARATOR = 'atc.'; public const EVENT_UPDATE_SEPARATOR = '.eus'; - - public const PLATFORM_EVENT_SEPARATOR = [ - 'github' => self::GITHUB_EVENT_SEPARATOR, - 'gitlab' => self::GITLAB_EVENT_SEPARATOR, - ]; } diff --git a/src/Constants/SettingConstant.php b/src/Constants/SettingConstant.php index 4bcaf65..2d2ed59 100644 --- a/src/Constants/SettingConstant.php +++ b/src/Constants/SettingConstant.php @@ -4,29 +4,29 @@ final class SettingConstant { - public const SETTING_PREFIX = 'stg.'; + public const string SETTING_PREFIX = 'stg.'; - public const T_IS_NOTIFIED = 'is_notified'; + public const string T_IS_NOTIFIED = 'is_notified'; - public const T_ALL_EVENTS_NOTIFICATION = 'all_events_notify'; + public const string T_ALL_EVENTS_NOTIFICATION = 'all_events_notify'; - public const SETTING_IS_NOTIFIED = self::SETTING_PREFIX . self::T_IS_NOTIFIED; + public const string SETTING_IS_NOTIFIED = self::SETTING_PREFIX . self::T_IS_NOTIFIED; - public const SETTING_ALL_EVENTS_NOTIFY = self::SETTING_PREFIX . self::T_ALL_EVENTS_NOTIFICATION; + public const string SETTING_ALL_EVENTS_NOTIFY = self::SETTING_PREFIX . self::T_ALL_EVENTS_NOTIFICATION; - public const SETTING_CUSTOM_EVENTS = self::SETTING_PREFIX . 'cus'; + public const string SETTING_CUSTOM_EVENTS = self::SETTING_PREFIX . 'cus'; - public const SETTING_GITHUB_EVENTS = self::SETTING_CUSTOM_EVENTS . EventConstant::GITHUB_EVENT_SEPARATOR; + public const string SETTING_GITHUB_EVENTS = self::SETTING_CUSTOM_EVENTS . 'gh.'; - public const SETTING_GITLAB_EVENTS = self::SETTING_CUSTOM_EVENTS . EventConstant::GITLAB_EVENT_SEPARATOR; + public const string SETTING_GITLAB_EVENTS = self::SETTING_CUSTOM_EVENTS . 'gl.'; - public const BTN_LINE_ITEM_COUNT = 2; + public const int BTN_LINE_ITEM_COUNT = 2; - public const SETTING_BACK = self::SETTING_PREFIX . 'back.'; + public const string SETTING_BACK = self::SETTING_PREFIX . 'back.'; - public const SETTING_BACK_TO_MAIN_MENU = self::SETTING_BACK . 'menu'; + public const string SETTING_BACK_TO_MAIN_MENU = self::SETTING_BACK . 'menu'; - public const SETTING_BACK_TO_EVENTS_MENU = self::SETTING_BACK . 'settings.custom_events'; + public const string SETTING_BACK_TO_EVENTS_MENU = self::SETTING_BACK . 'settings.custom_events'; - public const SETTING_BACK_TO_SETTINGS_MENU = self::SETTING_BACK . 'settings'; + public const string SETTING_BACK_TO_SETTINGS_MENU = self::SETTING_BACK . 'settings'; } diff --git a/src/DTOs/ChatTarget.php b/src/DTOs/ChatTarget.php new file mode 100644 index 0000000..846b387 --- /dev/null +++ b/src/DTOs/ChatTarget.php @@ -0,0 +1,51 @@ + $threadIds + */ + public function __construct( + public string $chatId, + public array $threadIds = [], + ) { + } + + public function hasThreads(): bool + { + return $this->threadIds !== []; + } + + /** + * Parse a single "chatId:thread1,thread2" string. + */ + public static function fromString(string $raw): self + { + [$chatId, $threadIds] = explode(':', $raw) + [null, null]; + + return new self( + chatId: (string) $chatId, + threadIds: $threadIds ? explode(',', $threadIds) : [], + ); + } + + /** + * Parse "id1;id2:t1,t2;id3" format into an array of ChatTarget. + * + * @return list + */ + public static function parseMultiple(string $raw): array + { + if (trim($raw) === '') { + return []; + } + + return array_map( + static fn (string $pair) => self::fromString($pair), + explode(';', $raw), + ); + } +} diff --git a/src/Enums/Platform.php b/src/Enums/Platform.php new file mode 100644 index 0000000..f2084ec --- /dev/null +++ b/src/Enums/Platform.php @@ -0,0 +1,54 @@ + 'gh.', + self::GITLAB => 'gl.', + }; + } + + public function webhookEventHeader(): string + { + return match ($this) { + self::GITHUB => 'HTTP_X_GITHUB_EVENT', + self::GITLAB => 'HTTP_X_GITLAB_EVENT', + }; + } + + /** + * @return array + */ + public static function webhookEventHeaders(): array + { + return array_combine( + array_map(fn (self $p) => $p->value, self::cases()), + array_map(fn (self $p) => $p->webhookEventHeader(), self::cases()), + ); + } + + /** + * @return array + */ + public static function eventSeparators(): array + { + return array_combine( + array_map(fn (self $p) => $p->value, self::cases()), + array_map(fn (self $p) => $p->eventSeparator(), self::cases()), + ); + } + + public static function default(): self + { + return self::GITHUB; + } +} diff --git a/src/Exceptions/InvalidViewTemplateException.php b/src/Exceptions/InvalidViewTemplateException.php index 070adf7..b2b10f3 100644 --- a/src/Exceptions/InvalidViewTemplateException.php +++ b/src/Exceptions/InvalidViewTemplateException.php @@ -8,7 +8,7 @@ final class InvalidViewTemplateException extends TelegramGitNotifierException { public static function create( string $view, - Throwable $previous = null + ?Throwable $previous = null ): self { return new self( "Invalid view template: {$view}", diff --git a/src/Helpers/ConfigHelper.php b/src/Helpers/ConfigHelper.php index d650e48..a0eb6a9 100644 --- a/src/Helpers/ConfigHelper.php +++ b/src/Helpers/ConfigHelper.php @@ -2,7 +2,6 @@ namespace CSlant\TelegramGitNotifier\Helpers; -use CSlant\TelegramGitNotifier\Exceptions\EntryNotFoundException; use CSlant\TelegramGitNotifier\Exceptions\InvalidViewTemplateException; use Throwable; @@ -11,7 +10,7 @@ final class ConfigHelper /** * @var array */ - public array $config; + private readonly array $config; public function __construct() { @@ -19,36 +18,36 @@ public function __construct() } /** - * Handle config and return value - * - * @param string $string - * - * @return array|mixed + * Resolve a dot-notation config key to its value. */ public function execConfig(string $string): mixed { - $config = explode('.', $string); + $keys = explode('.', $string); $result = $this->config; - foreach ($config as $value) { - if (!isset($result[$value])) { + + foreach ($keys as $key) { + if (!is_array($result) || !array_key_exists($key, $result)) { return ''; } - $result = $result[$value]; + $result = $result[$key]; } return $result; } /** - * Return template data + * Render a PHP template file with the given data. + * + * Uses output buffering and a closure to isolate the template scope, + * preventing variable pollution. * - * @param string $partialPath - * @param array $data + * @param string $partialPath Dot-notation path to the view file + * @param array $data Variables to pass to the template * - * @return bool|string + * @throws InvalidViewTemplateException */ - public function getTemplateData(string $partialPath, array $data = []): bool|string + public function getTemplateData(string $partialPath, array $data = []): string { $viewPathFile = $this->execConfig('telegram-git-notifier.view.path') . '/' . str_replace('.', '/', $partialPath) . '.php'; @@ -57,16 +56,24 @@ public function getTemplateData(string $partialPath, array $data = []): bool|str return ''; } - $content = ''; - ob_start(); - try { - extract($data, EXTR_SKIP); - require_once $viewPathFile; - $content = ob_get_clean(); - } catch (EntryNotFoundException|InvalidViewTemplateException|Throwable $e) { - ob_end_clean(); - error_log($e->getMessage()); + $content = (static function (string $__viewFile, array $__data): string { + extract($__data, EXTR_SKIP); + ob_start(); + require $__viewFile; + + return (string) ob_get_clean(); + })($viewPathFile, $data); + } catch (Throwable $e) { + if (ob_get_level() > 0) { + ob_end_clean(); + } + + throw new InvalidViewTemplateException( + "Failed to render template '{$partialPath}': {$e->getMessage()}", + 0, + $e, + ); } return $content; diff --git a/src/Interfaces/BotInterface.php b/src/Interfaces/BotInterface.php index a65e163..16a0935 100644 --- a/src/Interfaces/BotInterface.php +++ b/src/Interfaces/BotInterface.php @@ -11,7 +11,7 @@ interface BotInterface extends AppInterface, EventInterface, SettingInterface /** * Set the menu button for a telegram * - * @param array $menuCommand + * @param array $menuCommand * @param string|null $view * * @return void diff --git a/src/Interfaces/EventInterface.php b/src/Interfaces/EventInterface.php index 311af35..6a56cea 100644 --- a/src/Interfaces/EventInterface.php +++ b/src/Interfaces/EventInterface.php @@ -2,7 +2,7 @@ namespace CSlant\TelegramGitNotifier\Interfaces; -use CSlant\TelegramGitNotifier\Constants\EventConstant; +use CSlant\TelegramGitNotifier\Enums\Platform; use CSlant\TelegramGitNotifier\Exceptions\ConfigFileException; use CSlant\TelegramGitNotifier\Trait\ActionEventTrait; use Symfony\Component\HttpFoundation\Request; @@ -28,7 +28,7 @@ public function getActionOfEvent(object $payload): string; * @return void * @see EventTrait::setPlatFormForEvent() */ - public function setPlatFormForEvent(?string $platform = EventConstant::DEFAULT_PLATFORM, ?string $platformFile = null): void; + public function setPlatFormForEvent(?string $platform = Platform::DEFAULT, ?string $platformFile = null): void; /** * Set event config and get event name diff --git a/src/Interfaces/SettingInterface.php b/src/Interfaces/SettingInterface.php index 122f209..ce16b65 100644 --- a/src/Interfaces/SettingInterface.php +++ b/src/Interfaces/SettingInterface.php @@ -2,7 +2,7 @@ namespace CSlant\TelegramGitNotifier\Interfaces; -use CSlant\TelegramGitNotifier\Constants\EventConstant; +use CSlant\TelegramGitNotifier\Enums\Platform; use CSlant\TelegramGitNotifier\Exceptions\BotException; use CSlant\TelegramGitNotifier\Exceptions\InvalidViewTemplateException; use CSlant\TelegramGitNotifier\Exceptions\MessageIsEmptyException; @@ -17,17 +17,17 @@ interface SettingInterface * @param string|null $parentEvent * @param string $platform * - * @return array + * @return array> * @see EventSettingTrait::eventMarkup() */ - public function eventMarkup(?string $parentEvent = null, string $platform = EventConstant::DEFAULT_PLATFORM): array; + public function eventMarkup(?string $parentEvent = null, string $platform = Platform::DEFAULT): array; /** * Get callback data for markup * * @param string $event * @param string $platform - * @param array|bool $value + * @param array|bool $value * @param string|null $parentEvent * * @return string @@ -39,7 +39,7 @@ public function getCallbackData(string $event, string $platform, array|bool $val * Get event name for markup * * @param string $event - * @param bool|array $value + * @param bool|array $value * * @return string * @see EventSettingTrait::getEventName() @@ -52,7 +52,7 @@ public function getEventName(string $event, bool|array $value): string; * @param string $platform * @param string|null $parentEvent * - * @return array + * @return array * @see EventSettingTrait::getEndKeyboard() */ public function getEndKeyboard(string $platform, ?string $parentEvent = null): array; @@ -163,15 +163,15 @@ public function settingHandle(?string $view = null): void; /** * Generate setting markup * - * @return array[] + * @return array> * @see BotSettingTrait::settingMarkup() */ public function settingMarkup(): array; /** - * @param array $markup + * @param array> $markup * - * @return array + * @return array> * @see BotSettingTrait::customEventMarkup() */ public function customEventMarkup(array $markup): array; diff --git a/src/Interfaces/Structures/AppInterface.php b/src/Interfaces/Structures/AppInterface.php index 7a77365..ebd3d79 100644 --- a/src/Interfaces/Structures/AppInterface.php +++ b/src/Interfaces/Structures/AppInterface.php @@ -13,7 +13,7 @@ interface AppInterface * Send a message to telegram * * @param string|null $message - * @param array $options + * @param array $options * * @return void * @throws MessageIsEmptyException @@ -25,7 +25,7 @@ public function sendMessage(?string $message = '', array $options = []): void; * Send a photo to telegram * * @param string $photo (path to photo) - * @param array $caption + * @param array $caption * * @return void * @throws EntryNotFoundException @@ -37,31 +37,31 @@ public function sendPhoto(string $photo = '', array $caption = []): void; * Send callback response to telegram (show alert) * * @param string|null $text - * @param array $options + * @param array $options * * @return void * @throws MessageIsEmptyException * @throws CallbackException * @see App::answerCallbackQuery() */ - public function answerCallbackQuery(string $text = null, array $options = []): void; + public function answerCallbackQuery(?string $text = null, array $options = []): void; /** * Edit message text and reply markup * * @param string|null $text - * @param array $options + * @param array $options * * @return void * @throws BotException * @see App::editMessageText() */ - public function editMessageText(string $text = null, array $options = []): void; + public function editMessageText(?string $text = null, array $options = []): void; /** * Edit message reply markup from a telegram * - * @param array $options + * @param array $options * * @return void * @throws BotException @@ -80,9 +80,9 @@ public function getCallbackTextMessage(): string; /** * Create content for a callback message * - * @param array $options + * @param array $options * - * @return array + * @return array * @see App::setCallbackContentMessage() */ public function setCallbackContentMessage(array $options = []): array; @@ -93,7 +93,7 @@ public function setCallbackContentMessage(array $options = []): array; * @return void * @see App::setCurrentChatBotId() */ - public function setCurrentChatBotId(string $chatId): void; + public function setCurrentChatBotId(?string $chatId = null): void; /** * Get the username of the bot diff --git a/src/Interfaces/Structures/NotificationInterface.php b/src/Interfaces/Structures/NotificationInterface.php index a68457b..752ea4c 100644 --- a/src/Interfaces/Structures/NotificationInterface.php +++ b/src/Interfaces/Structures/NotificationInterface.php @@ -20,34 +20,34 @@ interface NotificationInterface extends AppInterface * @see Notification::accessDenied() */ public function accessDenied( - string $chatId = null, - string $viewTemplate = null, + ?string $chatId = null, + ?string $viewTemplate = null, ): void; /** - * Set payload from request + * Parse webhook payload from request and build notification message. * * @param Request $request * @param string $event * - * @return mixed|void + * @return object|null The parsed payload object, or null if content is empty. * @throws InvalidViewTemplateException * @throws MessageIsEmptyException * @see Notification::setPayload() */ - public function setPayload(Request $request, string $event); + public function setPayload(Request $request, string $event): ?object; /** * Send notification to telegram * * @param string|null $message - * @param array $options + * @param array $options * * @return bool * @throws SendNotificationException * @see Notification::sendNotify() */ - public function sendNotify(string $message = null, array $options = []): bool; + public function sendNotify(?string $message = null, array $options = []): bool; /** * Get action name of event from payload data @@ -65,7 +65,7 @@ public function getActionOfEvent(object $payload): string; * * @param string|null $chatIds * - * @return array + * @return array> */ public function parseNotifyChatIds(?string $chatIds = null): array; } diff --git a/src/Models/Event.php b/src/Models/Event.php index 4896b4c..1fdcba4 100644 --- a/src/Models/Event.php +++ b/src/Models/Event.php @@ -2,36 +2,30 @@ namespace CSlant\TelegramGitNotifier\Models; -use CSlant\TelegramGitNotifier\Constants\EventConstant; +use CSlant\TelegramGitNotifier\Enums\Platform; class Event { + /** @var array */ private array $eventConfig = []; - public string $platform = EventConstant::DEFAULT_PLATFORM; + private bool $dirty = false; - private string $platformFile = ''; + private string $lastLoadedFile = ''; - /** - * @return string - */ - public function getPlatformFile(): string - { - return $this->platformFile; - } + public string $platform = Platform::DEFAULT; - /** - * @param string $platformFile - * - * @return void - */ - public function setPlatformFile(string $platformFile): void - { - $this->platformFile = $platformFile; + public string $platformFile = '' { + get { + return $this->platformFile; + } + set { + $this->platformFile = $value; + } } /** - * @return array + * @return array */ public function getEventConfig(): array { @@ -39,54 +33,78 @@ public function getEventConfig(): array } /** - * Set event config - * - * @param string|null $platform - * - * @return void + * Set event config from JSON file with in-memory caching. + * Only reads from disk if the file hasn't been loaded yet or platform changes. */ - public function setEventConfig( - string $platform = null - ): void { - $this->platform = $platform ?? EventConstant::DEFAULT_PLATFORM; - - $json = file_get_contents($this->platformFile); + public function setEventConfig(?string $platform = null): void + { + $newPlatform = $platform ?? Platform::DEFAULT; + $this->platform = $newPlatform; - if (!empty($json)) { - $this->eventConfig = json_decode($json, true); + // Skip re-reading if same file is already loaded + if ($this->platformFile === $this->lastLoadedFile && $this->eventConfig !== []) { + return; } + + $this->loadFromFile(); + } + + /** + * Force reload event config from file. + */ + public function reloadEventConfig(): void + { + $this->loadFromFile(); } /** - * Update event config by event and action - * - * @param string $event - * @param string|null $action - * - * @return void + * Update event config by event and action. + * Toggle the boolean value for the specified event/action. */ - public function updateEvent(string $event, string|null $action): void + public function updateEvent(string $event, ?string $action): void { - if (!empty($action)) { + if ($action !== null && $action !== '') { $this->eventConfig[$event][$action] = !$this->eventConfig[$event][$action]; } else { $this->eventConfig[$event] = !$this->eventConfig[$event]; } - $this->saveEventConfig(); + $this->dirty = true; + $this->persist(); } - /** - * Save event config - * - * @return void - */ - private function saveEventConfig(): void + public function getPlatformEnum(): Platform { - if (file_exists($this->platformFile)) { - $json = json_encode($this->eventConfig, JSON_PRETTY_PRINT); - file_put_contents($this->platformFile, $json, LOCK_EX); + return Platform::from($this->platform); + } + + private function loadFromFile(): void + { + if ($this->platformFile === '' || !file_exists($this->platformFile)) { + $this->eventConfig = []; + + return; } + + $json = file_get_contents($this->platformFile); + + if ($json !== false && $json !== '') { + $this->eventConfig = json_decode($json, true) ?? []; + } + + $this->lastLoadedFile = $this->platformFile; + $this->dirty = false; + } + + private function persist(): void + { + if (!$this->dirty || !file_exists($this->platformFile)) { + return; + } + + $json = json_encode($this->eventConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + file_put_contents($this->platformFile, $json, LOCK_EX); + $this->dirty = false; } } diff --git a/src/Models/Setting.php b/src/Models/Setting.php index 6341143..f539057 100644 --- a/src/Models/Setting.php +++ b/src/Models/Setting.php @@ -6,91 +6,72 @@ class Setting { + /** @var array */ private array $settings = []; private string $settingFile = ''; + private bool $dirty = false; + /** - * @return array + * @return array */ public function getSettings(): array { return $this->settings; } - /** - * @param string $settingFile - * - * @return void - */ public function setSettingFile(string $settingFile): void { $this->settingFile = $settingFile; } - /** - * @return string - */ public function getSettingFile(): string { return $this->settingFile; } /** - * Set settings - * - * @return void + * Load settings from JSON file with in-memory caching. */ public function setSettingConfig(): void { - $json = file_get_contents($this->settingFile); - - if (!empty($json)) { - $this->settings = json_decode($json, true); + if ($this->settings !== []) { + return; } + + $this->loadFromFile(); } /** - * @return bool + * Force reload settings from file. */ - public function isAllEventsNotification(): bool + public function reloadSettings(): void { - if (!empty($this->settings) - && $this->settings[SettingConstant::T_ALL_EVENTS_NOTIFICATION] === true - ) { - return true; - } + $this->loadFromFile(); + } - return false; + public function isAllEventsNotification(): bool + { + return $this->settings !== [] + && ($this->settings[SettingConstant::T_ALL_EVENTS_NOTIFICATION] ?? false) === true; } - /** - * Condition for checking the notification status - * - * @return bool - */ public function isNotified(): bool { - if (!empty($this->settings) - && $this->settings[SettingConstant::T_IS_NOTIFIED] === true - ) { - return true; - } - - return false; + return $this->settings !== [] + && ($this->settings[SettingConstant::T_IS_NOTIFIED] ?? false) === true; } /** - * Update setting item value and save to file + * Update setting item value and save to file. * - * @param string $settingName - * @param array|string|bool|int|null $settingValue - * - * @return bool + * @param string $settingName Dot-notation key path + * @param mixed $settingValue New value (null = toggle boolean) */ public function updateSetting( string $settingName, - mixed $settingValue = null + mixed $settingValue = null, ): bool { $settingKeys = explode('.', $settingName); $lastKey = array_pop($settingKeys); @@ -106,29 +87,42 @@ public function updateSetting( } if (isset($nestedSettings[$lastKey])) { - $newValue = $settingValue ?? !$nestedSettings[$lastKey]; - $nestedSettings[$lastKey] = $newValue; + $nestedSettings[$lastKey] = $settingValue ?? !$nestedSettings[$lastKey]; + $this->dirty = true; - return $this->saveSettingsToFile(); + return $this->persist(); } return false; } - /** - * Save settings to json file - * - * @return bool - */ - private function saveSettingsToFile(): bool + private function loadFromFile(): void { - if (file_exists($this->settingFile)) { - $json = json_encode($this->settings, JSON_PRETTY_PRINT); - file_put_contents($this->settingFile, $json, LOCK_EX); + if ($this->settingFile === '' || !file_exists($this->settingFile)) { + $this->settings = []; - return true; + return; } - return false; + $json = file_get_contents($this->settingFile); + + if ($json !== false && $json !== '') { + $this->settings = json_decode($json, true) ?? []; + } + + $this->dirty = false; + } + + private function persist(): bool + { + if (!$this->dirty || !file_exists($this->settingFile)) { + return false; + } + + $json = json_encode($this->settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + file_put_contents($this->settingFile, $json, LOCK_EX); + $this->dirty = false; + + return true; } } diff --git a/src/Notifier.php b/src/Notifier.php index 62fbd08..bb5148a 100644 --- a/src/Notifier.php +++ b/src/Notifier.php @@ -2,8 +2,8 @@ namespace CSlant\TelegramGitNotifier; -use CSlant\TelegramGitNotifier\Constants\EventConstant; -use CSlant\TelegramGitNotifier\Constants\NotificationConstant; +use CSlant\TelegramGitNotifier\DTOs\ChatTarget; +use CSlant\TelegramGitNotifier\Enums\Platform; use CSlant\TelegramGitNotifier\Exceptions\ConfigFileException; use CSlant\TelegramGitNotifier\Interfaces\EventInterface; use CSlant\TelegramGitNotifier\Interfaces\Structures\NotificationInterface; @@ -26,22 +26,15 @@ class Notifier implements NotificationInterface, EventInterface public Client $client; /** - * @param Telegram|null $telegram - * @param string|null $chatBotId - * @param Event|null $event - * @param string|null $platform - * @param string|null $platformFile - * @param Client|null $client - * * @throws ConfigFileException */ public function __construct( - Telegram $telegram = null, + ?Telegram $telegram = null, ?string $chatBotId = null, - Event $event = null, - ?string $platform = EventConstant::DEFAULT_PLATFORM, + ?Event $event = null, + ?string $platform = Platform::DEFAULT, ?string $platformFile = null, - Client $client = null, + ?Client $client = null, ) { $this->event = $event ?? new Event(); $this->setPlatFormForEvent($platform, $platformFile); @@ -53,21 +46,40 @@ public function __construct( $this->client = $client ?? new Client(); } + /** + * Parse notification chat IDs string into structured array. + * + * @return array> Map of chatId => threadIds + */ public function parseNotifyChatIds(?string $chatIds = null): array { - $chatData = explode( - NotificationConstant::CHAT_ID_PAIRS_SEPARATOR, - $chatIds ?? config('telegram-git-notifier.bot.notify_chat_ids') - ); - $chatThreadMapping = []; - - foreach ($chatData as $data) { - [$chatId, $threadIds] = explode(NotificationConstant::CHAT_THREAD_ID_SEPARATOR, $data) + [null, null]; - $chatThreadMapping[$chatId] = $threadIds - ? explode(NotificationConstant::THREAD_ID_SEPARATOR, $threadIds) - : []; + $raw = $chatIds ?? config('telegram-git-notifier.bot.notify_chat_ids'); + + if ($raw === null || $raw === '') { + return []; + } + + $targets = ChatTarget::parseMultiple($raw); + $mapping = []; + + foreach ($targets as $target) { + if ($target->chatId !== '') { + $mapping[$target->chatId] = $target->threadIds; + } } - return $chatThreadMapping; + return $mapping; + } + + /** + * Parse notification chat IDs into ChatTarget DTOs. + * + * @return list + */ + public function getChatTargets(?string $chatIds = null): array + { + $raw = $chatIds ?? config('telegram-git-notifier.bot.notify_chat_ids'); + + return ChatTarget::parseMultiple($raw ?? ''); } } diff --git a/src/Objects/Validator.php b/src/Objects/Validator.php index bb7d9fd..3327a14 100644 --- a/src/Objects/Validator.php +++ b/src/Objects/Validator.php @@ -5,34 +5,40 @@ use CSlant\TelegramGitNotifier\Models\Event; use CSlant\TelegramGitNotifier\Models\Setting; use CSlant\TelegramGitNotifier\Trait\ActionEventTrait; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; class Validator { use ActionEventTrait; - private Setting $setting; + private readonly Setting $setting; - private Event $event; + private readonly Event $event; - public function __construct(Setting $setting, Event $event) - { + private readonly LoggerInterface $logger; + + public function __construct( + Setting $setting, + Event $event, + ?LoggerInterface $logger = null, + ) { $this->setting = $setting; $this->event = $event; + $this->logger = $logger ?? new NullLogger(); } /** - * Validate is access event before send notify - * - * @param string $platform Source code platform (GitHub, GitLab) - * @param string $event Event name (push, pull_request) - * @param object $payload + * Validate whether the event is allowed before sending notification. * - * @return bool + * @param string $platform Source code platform (github, gitlab) + * @param string $event Event name (push, pull_request, etc.) + * @param object $payload Webhook payload */ public function isAccessEvent( string $platform, string $event, - object $payload + object $payload, ): bool { if (!$this->setting->isNotified()) { return false; @@ -41,20 +47,25 @@ public function isAccessEvent( if ($this->setting->isAllEventsNotification()) { return true; } + $this->event->setEventConfig($platform); $eventConfig = $this->event->getEventConfig()[tgn_convert_event_name($event)] ?? false; $action = $this->getActionOfEvent($payload); - if (!empty($action) && isset($eventConfig[$action])) { + if ($action !== '' && isset($eventConfig[$action])) { $eventConfig = $eventConfig[$action]; } if (is_array($eventConfig) || !$eventConfig) { - $eventConfig = false; - error_log('\n Event config is not found \n'); + $this->logger->debug('Event config not found for event: {event}', [ + 'event' => $event, + 'platform' => $platform, + ]); + + return false; } - return $eventConfig; + return (bool) $eventConfig; } } diff --git a/src/Structures/App.php b/src/Structures/App.php index 6b8591c..beceaaf 100644 --- a/src/Structures/App.php +++ b/src/Structures/App.php @@ -15,11 +15,14 @@ trait App public string $chatBotId; - public function setCurrentChatBotId(string $chatBotId = null): void + public function setCurrentChatBotId(?string $chatBotId = null): void { $this->chatBotId = $chatBotId ?? config('telegram-git-notifier.bot.chat_id'); } + /** + * @return array + */ private function createTelegramBaseContent(): array { return [ @@ -29,16 +32,21 @@ private function createTelegramBaseContent(): array ]; } + /** + * @param array $options + * + * @throws MessageIsEmptyException + */ public function sendMessage(?string $message = '', array $options = []): void { - if (empty($message)) { + if ($message === null || $message === '') { throw MessageIsEmptyException::create(); } $content = $this->createTelegramBaseContent(); $content['text'] = $message; - if (!empty($options['reply_markup'])) { + if (isset($options['reply_markup'])) { $content['reply_markup'] = $this->telegram->buildInlineKeyBoard( $options['reply_markup'] ); @@ -50,9 +58,14 @@ public function sendMessage(?string $message = '', array $options = []): void $this->telegram->sendMessage($content); } + /** + * @param array $options + * + * @throws EntryNotFoundException + */ public function sendPhoto(string $photo = '', array $options = []): void { - if (empty($photo)) { + if ($photo === '') { throw EntryNotFoundException::fileNotFound(); } @@ -64,9 +77,15 @@ public function sendPhoto(string $photo = '', array $options = []): void $this->telegram->sendPhoto($content); } - public function answerCallbackQuery(string $text = null, array $options = []): void + /** + * @param array $options + * + * @throws MessageIsEmptyException + * @throws CallbackException + */ + public function answerCallbackQuery(?string $text = null, array $options = []): void { - if (empty($text)) { + if ($text === null || $text === '') { throw MessageIsEmptyException::create(); } @@ -82,6 +101,11 @@ public function answerCallbackQuery(string $text = null, array $options = []): v } } + /** + * @param array $options + * + * @throws BotException + */ public function editMessageText( ?string $text = null, array $options = [] @@ -97,6 +121,11 @@ public function editMessageText( } } + /** + * @param array $options + * + * @throws BotException + */ public function editMessageReplyMarkup(array $options = []): void { try { @@ -113,6 +142,10 @@ public function getCallbackTextMessage(): string return $this->telegram->Callback_Message()['text']; } + /** + * @param array $options + * @return array + */ public function setCallbackContentMessage(array $options = []): array { $content = [ @@ -122,7 +155,7 @@ public function setCallbackContentMessage(array $options = []): array 'parse_mode' => 'HTML', ]; - $content['reply_markup'] = $options['reply_markup'] + $content['reply_markup'] = isset($options['reply_markup']) ? $this->telegram->buildInlineKeyBoard($options['reply_markup']) : null; diff --git a/src/Structures/Notification.php b/src/Structures/Notification.php index 0f79066..68673e7 100644 --- a/src/Structures/Notification.php +++ b/src/Structures/Notification.php @@ -2,7 +2,7 @@ namespace CSlant\TelegramGitNotifier\Structures; -use CSlant\TelegramGitNotifier\Constants\EventConstant; +use CSlant\TelegramGitNotifier\Enums\Platform; use CSlant\TelegramGitNotifier\Exceptions\MessageIsEmptyException; use CSlant\TelegramGitNotifier\Exceptions\SendNotificationException; use GuzzleHttp\Exception\GuzzleException; @@ -15,8 +15,8 @@ trait Notification public string $message = ''; public function accessDenied( - string $chatId = null, - string $viewTemplate = null, + ?string $chatId = null, + ?string $viewTemplate = null, ): void { $this->telegram->sendMessage([ 'chat_id' => $this->chatBotId, @@ -29,18 +29,27 @@ public function accessDenied( ]); } - public function setPayload(Request $request, string $event) + /** + * Parse webhook payload from request and build notification message. + * + * @return object|null The parsed payload object, or null if content is empty. + * + * @throws MessageIsEmptyException + */ + public function setPayload(Request $request, string $event): ?object { - $content = null; + $content = match ($this->event->platform) { + 'gitlab' => $request->getContent(), + Platform::DEFAULT => $request->request->get('payload'), + default => null, + }; - if ($this->event->platform === 'gitlab') { - $content = $request->getContent(); - } elseif ($this->event->platform === EventConstant::DEFAULT_PLATFORM) { - $content = $request->request->get('payload'); + if (is_string($content) && $content !== '') { + $this->payload = json_decode($content); } - if (is_string($content)) { - $this->payload = json_decode($content); + if (!isset($this->payload)) { + return null; } $this->setMessage($event); @@ -49,11 +58,8 @@ public function setPayload(Request $request, string $event) } /** - * Set message from payload + * Build notification message from event template. * - * @param string $typeEvent - * - * @return void * @throws MessageIsEmptyException */ private function setMessage(string $typeEvent): void @@ -61,9 +67,9 @@ private function setMessage(string $typeEvent): void $event = tgn_event_name($typeEvent); $action = $this->getActionOfEvent($this->payload); - $viewTemplate = empty($action) - ? "events.{$this->event->platform}.{$event}.default" - : "events.{$this->event->platform}.{$event}.{$action}"; + $viewTemplate = $action !== '' + ? "events.{$this->event->platform}.{$event}.{$action}" + : "events.{$this->event->platform}.{$event}.default"; $viewResult = tgn_view($viewTemplate, [ 'payload' => $this->payload, @@ -77,35 +83,84 @@ private function setMessage(string $typeEvent): void $this->message = $viewResult; } - public function sendNotify(string $message = null, array $options = []): bool + /** + * Send notification message to Telegram. + * + * @param array $options + * + * @throws SendNotificationException + */ + #[\NoDiscard('The return value indicates whether the notification was sent successfully')] + public function sendNotify(?string $message = null, array $options = []): bool { - $this->message = !empty($message) ? $message : $this->message; + if ($message !== null && $message !== '') { + $this->message = $message; + } - if (trim($this->message) === config('telegram-git-notifier.view.ignore-message')) { + $ignoreMessage = config('telegram-git-notifier.view.ignore-message'); + if (trim($this->message) === $ignoreMessage) { return false; } - $queryParams = array_merge($this->createTelegramBaseContent(), ['text' => $this->message], $options); + $queryParams = array_merge( + $this->createTelegramBaseContent(), + ['text' => $this->message], + $options, + ); - $url = 'https://api.telegram.org/bot' . config('telegram-git-notifier.bot.token') . '/sendMessage'; + $url = 'https://api.telegram.org/bot' + . config('telegram-git-notifier.bot.token') + . '/sendMessage'; - try { - $options = [ - 'form_params' => $queryParams, - 'verify' => config('telegram-git-notifier.app.request_verify'), - ]; + $requestOptions = [ + 'form_params' => $queryParams, + 'verify' => config('telegram-git-notifier.app.request_verify'), + ]; - $response = $this->client->request('POST', $url, $options); + return $this->sendWithRetry($url, $requestOptions); + } - if ($response->getStatusCode() === 200) { - return true; - } + /** + * Send HTTP request with exponential backoff retry for rate limits. + * + * @param array $options + * + * @throws SendNotificationException + */ + private function sendWithRetry(string $url, array $options, int $maxRetries = 3): bool + { + $attempt = 0; + + while (true) { + try { + $response = $this->client->request('POST', $url, $options); - $body = (string) $response->getBody(); + if ($response->getStatusCode() === 200) { + return true; + } - throw SendNotificationException::create($body); - } catch (GuzzleException $e) { - throw SendNotificationException::create($e->getMessage()); + $body = (string) $response->getBody(); + + // Telegram returns 429 for rate limiting + if ($response->getStatusCode() === 429 && $attempt < $maxRetries) { + $retryAfter = json_decode($body, true)['parameters']['retry_after'] ?? (2 ** $attempt); + usleep((int) ($retryAfter * 1_000_000)); + $attempt++; + + continue; + } + + throw SendNotificationException::create($body); + } catch (GuzzleException $e) { + if ($attempt < $maxRetries && str_contains($e->getMessage(), '429')) { + usleep(2 ** $attempt * 1_000_000); + $attempt++; + + continue; + } + + throw SendNotificationException::create($e->getMessage()); + } } } } diff --git a/src/Structures/TelegramBot.php b/src/Structures/TelegramBot.php index f2e0df9..9b411ed 100644 --- a/src/Structures/TelegramBot.php +++ b/src/Structures/TelegramBot.php @@ -18,16 +18,15 @@ public function isMessage(): bool public function isOwner(): bool { - return $this->telegram->ChatID() == $this->chatBotId; + return (string) $this->telegram->ChatID() === (string) $this->chatBotId; } public function isNotifyChat(): bool { $chatIds = config('telegram-git-notifier.bot.notify_chat_ids'); - if (in_array($this->telegram->ChatID(), $chatIds)) { - return true; - } - return false; + $notifyChatIds = ChatTarget::parseNotifyChatIds($chatIds); + + return in_array((string) $this->telegram->ChatID(), $notifyChatIds, true); } } diff --git a/src/Trait/ActionEventTrait.php b/src/Trait/ActionEventTrait.php index 9b1e6ac..552dc38 100644 --- a/src/Trait/ActionEventTrait.php +++ b/src/Trait/ActionEventTrait.php @@ -7,11 +7,11 @@ trait ActionEventTrait public function getActionOfEvent(object $payload): string { $action = $payload->action - ?? $payload->object_attributes?->action - ?? $payload->object_attributes?->noteable_type + ?? $payload->object_attributes->action + ?? $payload->object_attributes->noteable_type ?? ''; - if (!empty($action)) { + if ($action !== '') { return tgn_convert_action_name($action); } diff --git a/src/Trait/BotSettingTrait.php b/src/Trait/BotSettingTrait.php index 49393bb..0d828c5 100644 --- a/src/Trait/BotSettingTrait.php +++ b/src/Trait/BotSettingTrait.php @@ -3,6 +3,7 @@ namespace CSlant\TelegramGitNotifier\Trait; use CSlant\TelegramGitNotifier\Constants\SettingConstant; +use CSlant\TelegramGitNotifier\Exceptions\MessageIsEmptyException; trait BotSettingTrait { @@ -16,6 +17,11 @@ public function updateSetting(?string $settingFile = null): void $this->setting->setSettingConfig(); } + /** + * @param array $menuCommand + * + * @throws MessageIsEmptyException + */ public function setMyCommands( array $menuCommand, ?string $view = null @@ -39,6 +45,9 @@ public function settingHandle(?string $view = null): void ); } + /** + * @return array> + */ public function settingMarkup(): array { $markup = [ @@ -75,6 +84,10 @@ public function settingMarkup(): array return $markup; } + /** + * @param array> $markup + * @return array> + */ public function customEventMarkup(array $markup): array { if (!$this->setting->getSettings()[SettingConstant::T_ALL_EVENTS_NOTIFICATION]) { diff --git a/src/Trait/EventSettingTrait.php b/src/Trait/EventSettingTrait.php index 2a0cb99..baf5949 100644 --- a/src/Trait/EventSettingTrait.php +++ b/src/Trait/EventSettingTrait.php @@ -4,13 +4,17 @@ use CSlant\TelegramGitNotifier\Constants\EventConstant; use CSlant\TelegramGitNotifier\Constants\SettingConstant; +use CSlant\TelegramGitNotifier\Enums\Platform; trait EventSettingTrait { + /** + * @return array> + */ public function eventMarkup( ?string $parentEvent = null, - string $platform = EventConstant::DEFAULT_PLATFORM, - string $platformFile = null + string $platform = Platform::DEFAULT, + ?string $platformFile = null ): array { $replyMarkup = $replyMarkupItem = []; @@ -44,15 +48,16 @@ public function eventMarkup( return $replyMarkup; } + /** + * @param array|bool $value + */ public function getCallbackData( string $event, string $platform, array|bool $value = false, ?string $parentEvent = null ): string { - $platformSeparator = $platform === EventConstant::DEFAULT_PLATFORM - ? EventConstant::GITHUB_EVENT_SEPARATOR - : EventConstant::GITLAB_EVENT_SEPARATOR; + $platformSeparator = Platform::from($platform)->eventSeparator(); $prefix = EventConstant::EVENT_PREFIX . $platformSeparator; if (is_array($value)) { @@ -65,6 +70,9 @@ public function getCallbackData( return $prefix . $event . EventConstant::EVENT_UPDATE_SEPARATOR; } + /** + * @param bool|array $value + */ public function getEventName(string $event, bool|array $value = false): string { if (is_array($value)) { @@ -76,6 +84,9 @@ public function getEventName(string $event, bool|array $value = false): string return '❌ ' . $event; } + /** + * @return array + */ public function getEndKeyboard( string $platform, ?string $parentEvent = null @@ -123,13 +134,15 @@ public function getPlatformFromCallback( return $platform; } - if ($callback && str_contains($callback, EventConstant::GITHUB_EVENT_SEPARATOR)) { - return 'github'; - } elseif ($callback && str_contains($callback, EventConstant::GITLAB_EVENT_SEPARATOR)) { - return 'gitlab'; + if ($callback) { + foreach (Platform::cases() as $p) { + if (str_contains($callback, $p->eventSeparator())) { + return $p->value; + } + } } - return EventConstant::DEFAULT_PLATFORM; + return Platform::DEFAULT; } public function sendSettingEventMessage( @@ -161,11 +174,16 @@ public function getEventFromCallback(?string $callback): string return ''; } - return str_replace([ - EventConstant::EVENT_PREFIX, - EventConstant::GITHUB_EVENT_SEPARATOR, - EventConstant::GITLAB_EVENT_SEPARATOR, - ], '', $callback); + $separators = array_map( + fn (Platform $p) => $p->eventSeparator(), + Platform::cases(), + ); + + return str_replace( + [EventConstant::EVENT_PREFIX, ...$separators], + '', + $callback, + ); } public function handleEventWithActions( @@ -221,7 +239,7 @@ public function eventUpdateHandle( $this->event->updateEvent($event, $action); $this->eventHandle( $action - ? EventConstant::PLATFORM_EVENT_SEPARATOR[$platform] + ? Platform::from($platform)->eventSeparator() . EventConstant::EVENT_HAS_ACTION_SEPARATOR . $event : null, $platform diff --git a/src/Trait/EventTrait.php b/src/Trait/EventTrait.php index b6aed21..809f8f6 100644 --- a/src/Trait/EventTrait.php +++ b/src/Trait/EventTrait.php @@ -2,7 +2,7 @@ namespace CSlant\TelegramGitNotifier\Trait; -use CSlant\TelegramGitNotifier\Constants\EventConstant; +use CSlant\TelegramGitNotifier\Enums\Platform; use CSlant\TelegramGitNotifier\Exceptions\ConfigFileException; use Symfony\Component\HttpFoundation\Request; @@ -10,19 +10,21 @@ trait EventTrait { use ActionEventTrait; - public function setPlatFormForEvent(?string $platform = EventConstant::DEFAULT_PLATFORM, ?string $platformFile = null): void - { - /** @var array $platformFileDefaults */ + public function setPlatFormForEvent( + ?string $platform = Platform::DEFAULT, + ?string $platformFile = null, + ): void { + /** @var array $platformFileDefaults */ $platformFileDefaults = config('telegram-git-notifier.data_file.platform'); - $this->event->setPlatformFile($platformFile ?? $platformFileDefaults[$platform]); + $this->event->platformFile = $platformFile ?? $platformFileDefaults[$platform ?? Platform::DEFAULT] ?? ''; $this->event->setEventConfig($platform); } public function handleEventFromRequest(Request $request): ?string { - foreach (EventConstant::WEBHOOK_EVENT_HEADER as $platform => $header) { + foreach (Platform::webhookEventHeaders() as $platform => $header) { $event = $request->server->get($header); - if (!is_null($event)) { + if ($event !== null) { $this->event->platform = $platform; $this->setPlatFormForEvent($platform); @@ -35,10 +37,10 @@ public function handleEventFromRequest(Request $request): ?string public function validatePlatformFile(): void { - if (empty($this->event->getEventConfig())) { + if ($this->event->getEventConfig() === []) { throw ConfigFileException::platformFile( $this->event->platform, - $this->event->getPlatformFile() + $this->event->platformFile, ); } } diff --git a/src/Webhook.php b/src/Webhook.php index bd4eb07..8059906 100644 --- a/src/Webhook.php +++ b/src/Webhook.php @@ -13,11 +13,11 @@ class Webhook implements WebhookInterface private string $url; - private Client $client; + private readonly Client $client; - public function __construct() + public function __construct(?Client $client = null) { - $this->client = new Client(); + $this->client = $client ?? new Client(); } public function setToken(string $token): void @@ -32,65 +32,81 @@ public function setUrl(string $url): void public function setWebhook(): string { - $url = "https://api.telegram.org/bot{$this->token}/setWebhook?url={$this->url}"; - $options = [ - 'verify' => config('telegram-git-notifier.app.request_verify'), - ]; - - try { - $response = $this->client->request('GET', $url, $options); - - return $response->getBody()->getContents(); - } catch (GuzzleException) { - throw WebhookException::set(); - } + return $this->callTelegramApi( + "setWebhook?url={$this->url}", + static fn () => WebhookException::set(), + ); } public function deleteWebHook(): string { - $url = "https://api.telegram.org/bot{$this->token}/deleteWebhook"; - $options = [ - 'verify' => config('telegram-git-notifier.app.request_verify'), - ]; - - try { - $response = $this->client->request('GET', $url, $options); - - return $response->getBody()->getContents(); - } catch (GuzzleException) { - throw WebhookException::delete(); - } + return $this->callTelegramApi( + 'deleteWebhook', + static fn () => WebhookException::delete(), + ); } public function getWebHookInfo(): string { - $url = "https://api.telegram.org/bot{$this->token}/getWebhookInfo"; - $options = [ - 'verify' => config('telegram-git-notifier.app.request_verify'), - ]; - - try { - $response = $this->client->request('GET', $url, $options); - - return $response->getBody()->getContents(); - } catch (GuzzleException) { - throw WebhookException::getWebHookInfo(); - } + return $this->callTelegramApi( + 'getWebhookInfo', + static fn () => WebhookException::getWebHookInfo(), + ); } public function getUpdates(): string { - $url = "https://api.telegram.org/bot{$this->token}/getUpdates"; + return $this->callTelegramApi( + 'getUpdates', + static fn () => WebhookException::getUpdates(), + ); + } + + /** + * Centralized Telegram API call with retry support. + * + * @param string $endpoint API endpoint (after /bot{token}/) + * @param callable(): WebhookException $exceptionFactory + * @param int $maxRetries Max retry attempts for rate limiting + * + * @throws WebhookException + */ + private function callTelegramApi(string $endpoint, callable $exceptionFactory, int $maxRetries = 3): string + { + $url = "https://api.telegram.org/bot{$this->token}/{$endpoint}"; $options = [ 'verify' => config('telegram-git-notifier.app.request_verify'), ]; - try { - $response = $this->client->request('GET', $url, $options); + $attempt = 0; + + while (true) { + try { + $response = $this->client->request('GET', $url, $options); + + return $response->getBody()->getContents(); + } catch (GuzzleException $e) { + if ($attempt < $maxRetries && str_contains($e->getMessage(), '429')) { + usleep(2 ** $attempt * 1_000_000); + $attempt++; + + continue; + } + + $baseException = $exceptionFactory(); + $message = $baseException->getMessage(); + + if ($e->getMessage() !== '') { + $suffix = 'GuzzleException: ' . $e->getMessage(); + $message = $message !== '' ? $message . ' | ' . $suffix : $suffix; + } - return $response->getBody()->getContents(); - } catch (GuzzleException) { - throw WebhookException::getUpdates(); + throw new WebhookException( + $message, + $baseException->getCode(), + $e + ); + } } } } diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index dd0ea7a..da57c6c 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -23,7 +23,7 @@ it('platform can be set for event with platform file', function () { $this->bot->setPlatFormForEvent('gitlab', 'storage/json/tgn/gitlab-events.json'); expect($this->bot->event->platform)->toBe('gitlab') - ->and($this->bot->event->getPlatformFile()) + ->and($this->bot->event->platformFile) ->toBe('storage/json/tgn/gitlab-events.json'); });