diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Services/BotTokenConfigurator.php b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Services/BotTokenConfigurator.php index fc61338..23e1b9b 100644 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Services/BotTokenConfigurator.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/bastion/Services/BotTokenConfigurator.php @@ -11,7 +11,7 @@ use Openguru\OpenCartFramework\Config\Settings; use Openguru\OpenCartFramework\Logger\LoggerInterface; use Openguru\OpenCartFramework\Router\Router; use Openguru\OpenCartFramework\Support\Arr; -use Openguru\OpenCartFramework\Telegram\TelegramClientException; +use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException; use Openguru\OpenCartFramework\Telegram\TelegramService; class BotTokenConfigurator diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Cache/DatabaseCache.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Cache/DatabaseCache.php index 020b78d..f7e827c 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Cache/DatabaseCache.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Cache/DatabaseCache.php @@ -3,7 +3,6 @@ namespace Openguru\OpenCartFramework\Cache; use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface; -use Openguru\BulkProducts\Modules\Shared\Cache\CacheInterface; class DatabaseCache implements CacheInterface { diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Http/Request.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Http/Request.php index 1de0b8c..30dac5c 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Http/Request.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Http/Request.php @@ -5,7 +5,7 @@ namespace Openguru\OpenCartFramework\Http; use Openguru\OpenCartFramework\Support\Arr; use Openguru\OpenCartFramework\Support\Utils; -class Request +final class Request { private $query; private $request; diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Commands/ChatIdCommand.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Commands/ChatIdCommand.php new file mode 100644 index 0000000..bbae8ab --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Commands/ChatIdCommand.php @@ -0,0 +1,15 @@ +telegram->sendMessage($chatId, $message); + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Commands/TelegramCommand.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Commands/TelegramCommand.php new file mode 100644 index 0000000..dad39e6 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Commands/TelegramCommand.php @@ -0,0 +1,19 @@ +telegram = $telegram; + $this->state = $stateManager; + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Contracts/TelegramCommandInterface.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Contracts/TelegramCommandInterface.php new file mode 100644 index 0000000..1a00a46 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/Contracts/TelegramCommandInterface.php @@ -0,0 +1,8 @@ +cache = $cache; + } + + public function getStateKey(string $userId, string $chatId): string + { + return md5('tg-state-' . $userId . '-' . $chatId); + } + + public function setState(string $handler, string $userId, string $chatId, array $data = []): void + { + $payload = [ + 'handler' => $handler, + 'user_id' => $userId, + 'chat_id' => $chatId, + 'data' => $data, + ]; + + $this->cache->set($this->getStateKey($userId, $chatId), $payload, 120); + } + + public function getState(string $userId, string $chatId): ?array + { + return $this->cache->get($this->getStateKey($userId, $chatId)); + } + + public function hasState(string $userId, string $chatId): bool + { + return ! empty($this->cache->get($this->getStateKey($userId, $chatId))); + } + + public function clearState(string $userId, string $chatId): void + { + $this->cache->delete($this->getStateKey($userId, $chatId)); + } + + public function getCurrentStateCommandHandler(string $userId, string $chatId): string + { + $state = $this->getState($userId, $chatId); + + return $state['handler']; + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramCommandsRegistry.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramCommandsRegistry.php new file mode 100644 index 0000000..8b2fe2e --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramCommandsRegistry.php @@ -0,0 +1,27 @@ +commands[$command] = [ + 'handler' => $handler, + 'description' => $description, + ]; + } + + public function resolve(string $command): string + { + if (! array_key_exists($command, $this->commands)) { + throw new TelegramCommandNotFoundException($command); + } + + return $this->commands[$command]['handler']; + } +} diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramService.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramService.php index 826fa15..1a5cdd5 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramService.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Telegram/TelegramService.php @@ -6,6 +6,8 @@ use GuzzleHttp\Client; use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use Openguru\OpenCartFramework\Support\Arr; +use Openguru\OpenCartFramework\Telegram\Enums\ChatAction; +use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramClientException; class TelegramService { @@ -22,6 +24,7 @@ class TelegramService foreach ($specials as $char) { $text = str_replace($char, '\\' . $char, $text); } + return $text; } @@ -32,23 +35,35 @@ class TelegramService return str_replace(array_keys($variables), $values, $template); } - public function sendMessage(int $chatId, string $text): void - { + /** + * @throws TelegramClientException + * @throws GuzzleException + * @throws \JsonException + */ + public function sendMessage( + int $chatId, + string $text, + array $replyMarkup = [], + string $chatAction = ChatAction::TYPING, + string $parseMode = 'MarkdownV2' + ): void { if (! $this->botToken) { return; } - $client = $this->createGuzzleClient("https://api.telegram.org/bot{$this->botToken}/"); + $this->sendChatAction($chatId, $chatAction); - $query = [ + $params = [ 'chat_id' => $chatId, 'text' => $text, - 'parse_mode' => 'MarkdownV2', + 'parse_mode' => $parseMode, ]; - $client->get('sendMessage', [ - 'query' => $query, - ]); + if ($replyMarkup) { + $params['reply_markup'] = json_encode($replyMarkup, JSON_THROW_ON_ERROR); + } + + $this->exec('sendMessage', $params); } private function createGuzzleClient(string $uri): Client @@ -113,4 +128,54 @@ class TelegramService return Arr::get($webhookInfo, 'result.url'); } + + public function sendChatAction(int $chatId, string $action): void + { + $this->exec('sendChatAction', [ + 'chat_id' => $chatId, + 'action' => $action, + ]); + } + + public function escapeTgSpecialCharacters(string $text): string + { + // Набор спецсимволов для MarkdownV2 + $specials = '_*[]()~`>#+-=|{}.!'; + + $out = ''; + $len = strlen($text); // работаем побайтно: спецсимволы — ASCII + $prevWasBackslash = false; + + for ($i = 0; $i < $len; $i++) { + $ch = $text[$i]; + + if ($prevWasBackslash) { + // Предыдущий был "\", этот символ считаем уже экранированным — просто добавим как есть + $out .= $ch; + $prevWasBackslash = false; + continue; + } + + if ($ch === '\\') { + // Запоминаем, что встретили слеш; пока не знаем, что дальше + $out .= '\\'; + $prevWasBackslash = true; + continue; + } + + // Если текущий символ — спецсимвол и он НЕ экранирован (мы уже знаем, что предыдущий не "\") + if (strpos($specials, $ch) !== false) { + $out .= '\\' . $ch; + } else { + $out .= $ch; + } + } + + // Если строка закончилась на одиночный "\" — довэкранируем его + if ($prevWasBackslash) { + $out .= '\\'; + } + + return $out; + } } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/ErrorBag.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/ErrorBag.php index 521ec85..45fb6e4 100644 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/ErrorBag.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/ErrorBag.php @@ -21,11 +21,13 @@ class ErrorBag $this->errors[$field][] = $message; } - public function first(): array + public function first(): ?array { foreach ($this->errors as $error) { return $error; } + + return null; } public function firstOfAll(): array diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/Validator.php b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/Validator.php index ed7bdee..b5de6df 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/Validator.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/framework/Validator/Validator.php @@ -11,6 +11,7 @@ class Validator implements ValidatorInterface private ErrorBag $errors; private array $customMessages; private array $fieldNames; + private array $validationRules; public function __construct(array $validationRules = [], array $customMessages = []) { diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/CustomExceptionHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/CustomExceptionHandler.php index 537c79b..b5d079b 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/CustomExceptionHandler.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Exceptions/CustomExceptionHandler.php @@ -5,7 +5,7 @@ namespace App\Exceptions; use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface; use Openguru\OpenCartFramework\Http\JsonResponse; use Openguru\OpenCartFramework\Http\Response; -use Openguru\OpenCartFramework\Telegram\TelegramInvalidSignatureException; +use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureException; use Throwable; class CustomExceptionHandler implements ExceptionHandlerInterface diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelegramHandler.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelegramHandler.php index 8a2d9fc..d6173df 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelegramHandler.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Handlers/TelegramHandler.php @@ -2,25 +2,90 @@ namespace App\Handlers; +use GuzzleHttp\Exception\GuzzleException; +use Mockery\Exception; use Openguru\OpenCartFramework\Cache\CacheInterface; +use Openguru\OpenCartFramework\Container\Container; use Openguru\OpenCartFramework\Http\JsonResponse; use Openguru\OpenCartFramework\Http\Request; +use Openguru\OpenCartFramework\Logger\LoggerInterface; use Openguru\OpenCartFramework\Support\Arr; +use Openguru\OpenCartFramework\Telegram\Contracts\TelegramCommandInterface; +use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramCommandNotFoundException; +use Openguru\OpenCartFramework\Telegram\TelegramBotStateManager; +use Openguru\OpenCartFramework\Telegram\TelegramCommandsRegistry; +use Openguru\OpenCartFramework\Telegram\TelegramService; class TelegramHandler { private CacheInterface $cache; + private TelegramCommandsRegistry $telegramCommandsRegistry; + private Container $container; + private TelegramBotStateManager $telegramBotStateManager; + private LoggerInterface $logger; + private TelegramService $telegramService; - public function __construct(CacheInterface $cache) - { + public function __construct( + CacheInterface $cache, + TelegramCommandsRegistry $telegramCommandsRegistry, + Container $container, + TelegramBotStateManager $telegramBotStateManager, + LoggerInterface $logger, + TelegramService $telegramService + ) { $this->cache = $cache; + $this->telegramCommandsRegistry = $telegramCommandsRegistry; + $this->container = $container; + $this->telegramBotStateManager = $telegramBotStateManager; + $this->logger = $logger; + $this->telegramService = $telegramService; } + /** + * @throws GuzzleException + * @throws \JsonException + */ public function webhook(Request $request): JsonResponse { - $message = Arr::get($request->json(), 'message', []); + $update = $request->json(); + $userId = $update['message']['from']['id']; + $chatId = $update['message']['chat']['id']; - $this->cache->set('tg_latest_msg', $message, 60); + try { + $message = Arr::get($update, 'message', []); + + $this->cache->set('tg_latest_msg', $message, 60); + + $text = Arr::get($message, 'text', ''); + + // command starts from "/" + if (strpos($text, '/') === 0) { + $this->telegramBotStateManager->clearState($userId, $chatId); + $command = substr($text, 1); + $handler = $this->telegramCommandsRegistry->resolve($command); + + /** @var TelegramCommandInterface $concrete */ + $concrete = $this->container->get($handler); + $concrete->handle($update); + + return new JsonResponse([]); + } + + // Continue state + $hasState = $this->telegramBotStateManager->hasState($userId, $chatId); + if ($hasState) { + $handler = $this->telegramBotStateManager->getCurrentStateCommandHandler($userId, $chatId); + /** @var TelegramCommandInterface $concrete */ + $concrete = $this->container->get($handler); + $concrete->handle($update); + + return new JsonResponse([]); + } + } catch (TelegramCommandNotFoundException $exception) { + $this->telegramService->sendMessage($chatId, 'Неверная команда'); + } catch (Exception $exception) { + $this->logger->logException($exception); + } return new JsonResponse([]); } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/ServiceProviders/AppServiceProvider.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/ServiceProviders/AppServiceProvider.php index 286b790..1165280 100755 --- a/module/oc_telegram_shop/upload/oc_telegram_shop/src/ServiceProviders/AppServiceProvider.php +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/ServiceProviders/AppServiceProvider.php @@ -3,8 +3,11 @@ namespace App\ServiceProviders; use App\Exceptions\CustomExceptionHandler; +use App\Telegram\LinkCommand; use Openguru\OpenCartFramework\Container\ServiceProvider; use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface; +use Openguru\OpenCartFramework\Telegram\Commands\ChatIdCommand; +use Openguru\OpenCartFramework\Telegram\TelegramCommandsRegistry; class AppServiceProvider extends ServiceProvider { @@ -13,5 +16,19 @@ class AppServiceProvider extends ServiceProvider $this->container->singleton(ExceptionHandlerInterface::class, function () { return new CustomExceptionHandler(); }); + + $this->registerTelegramCommands(); + } + + private function registerTelegramCommands(): void + { + $this->container->singleton(TelegramCommandsRegistry::class, function () { + return new TelegramCommandsRegistry(); + }); + + $registry = $this->container->get(TelegramCommandsRegistry::class); + + $registry->addCommand('id', ChatIdCommand::class, 'Возвращает ChatID текущего чата.'); + $registry->addCommand('link', LinkCommand::class, 'Генератор Telegram сообщений с кнопкой'); } } diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/src/Telegram/LinkCommand.php b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Telegram/LinkCommand.php new file mode 100644 index 0000000..426eae1 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/src/Telegram/LinkCommand.php @@ -0,0 +1,148 @@ +logger = $logger; + } + + /** + * @throws GuzzleException + * @throws JsonException + */ + public function handle(array $update): void + { + try { + $userId = $update['message']['from']['id']; + $chatId = $update['message']['chat']['id']; + + $state = $this->state->getState($userId, $chatId); + + if (! $state) { + $greeting = $this->telegram->escapeTgSpecialCharacters( + <<telegram->sendMessage($chatId, $greeting); + + $this->state->setState(self::class, $userId, $chatId, [ + 'step' => 'message_text', + 'data' => [ + 'message_text' => '', + 'btn_text' => '', + 'btn_link' => '', + ], + ]); + + return; + } + + $step = $state['data']['step']; + + if ($step === 'message_text') { + $message = $update['message']['text']; + $state['data']['data']['message_text'] = $message; + $state['data']['step'] = 'btn_text'; + $this->state->setState(self::class, $userId, $chatId, $state['data']); + + $text = <<telegram->sendMessage($chatId, $text); + + return; + } + + if ($step === 'btn_text') { + $message = $update['message']['text']; + $state['data']['data']['btn_text'] = $message; + $state['data']['step'] = 'btn_link'; + $this->state->setState(self::class, $userId, $chatId, $state['data']); + + $template = <<telegram->prepareMessage($template, [ + '{LINK}' => 'https://telecart-labs.github.io/docs/telegram/telegram/#direct-link', + ]); + $this->telegram->sendMessage($chatId, $text); + + return; + } + + if ($step === 'btn_link') { + $message = $update['message']['text']; + $state['data']['data']['btn_link'] = $message; + $this->state->setState(self::class, $userId, $chatId, $state['data']); + + $messageText = Arr::get($state, 'data.data.message_text', 'Текст сообщения'); + $btnText = $this->telegram->escapeTgSpecialCharacters( + Arr::get($state, 'data.data.btn_text', 'Открыть магазин') + ); + $btnLink = $message; + + $replyMarkup = [ + 'inline_keyboard' => [ + [ + [ + 'text' => $btnText, + 'url' => $btnLink, + ] + ] + ], + ]; + + $this->telegram->sendMessage( + $chatId, + $this->telegram->escapeTgSpecialCharacters($messageText), + $replyMarkup, + ); + } + + $this->state->clearState($userId, $chatId); + } catch (ClientException $exception) { + $this->telegram->sendMessage($chatId, 'Ошибка: ' . $exception->getResponse()->getBody()->getContents()); + } catch (Throwable $exception) { + $this->logger->logException($exception); + $this->telegram->sendMessage($chatId, 'Произошла ошибка'); + } + } +} \ No newline at end of file diff --git a/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Telegram/TelegramServiceTest.php b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Telegram/TelegramServiceTest.php new file mode 100644 index 0000000..1497823 --- /dev/null +++ b/module/oc_telegram_shop/upload/oc_telegram_shop/tests/Telegram/TelegramServiceTest.php @@ -0,0 +1,69 @@ +service = new TelegramService(); + } + + public function testDoesNotEscapeNormalCharacters(): void + { + $this->assertEquals('hello world', $this->service->escapeTgSpecialCharacters('hello world')); + } + + public function testEscapesSingleSpecialCharacters(): void + { + $this->assertEquals('\_', $this->service->escapeTgSpecialCharacters('_')); + $this->assertEquals('\#', $this->service->escapeTgSpecialCharacters('#')); + $this->assertEquals('\+', $this->service->escapeTgSpecialCharacters('+')); + $this->assertEquals('\(', $this->service->escapeTgSpecialCharacters('(')); + $this->assertEquals('\)', $this->service->escapeTgSpecialCharacters(')')); + $this->assertEquals('\!', $this->service->escapeTgSpecialCharacters('!')); + } + + public function testDoesNotDoubleEscapeAlreadyEscaped(): void + { + $this->assertEquals('\#', $this->service->escapeTgSpecialCharacters('\#')); + $this->assertEquals('\\\\', $this->service->escapeTgSpecialCharacters('\\\\')); // двойной бэкслеш остаётся двойным + } + + public function testEscapesInsideText(): void + { + $this->assertEquals('price is 100\#', $this->service->escapeTgSpecialCharacters('price is 100#')); + $this->assertEquals('a\(b\)c', $this->service->escapeTgSpecialCharacters('a(b)c')); + $this->assertEquals('a\+b', $this->service->escapeTgSpecialCharacters('a+b')); + } + + public function testEscapesBackslashAtEnd(): void + { + // висячий бэкслеш должен быть продублирован + $this->assertEquals('backslash\\\\', $this->service->escapeTgSpecialCharacters('backslash\\')); + } + + public function testDoesNotEscapeEscapedSpecialCharacter(): void + { + // \# должен остаться \# + $this->assertEquals('\#tag', $this->service->escapeTgSpecialCharacters('\#tag')); + } + + public function testMultipleSpecialCharactersInRow(): void + { + $this->assertEquals('\#\+\=', $this->service->escapeTgSpecialCharacters('#+=')); + $this->assertEquals('\#\+\=text', $this->service->escapeTgSpecialCharacters('#+=text')); + } + + public function testEmojiAndMultibyteCharactersAreUntouched(): void + { + $this->assertEquals('Привет 👋', $this->service->escapeTgSpecialCharacters('Привет 👋')); + $this->assertEquals('emoji\#', $this->service->escapeTgSpecialCharacters('emoji#')); + } +}