feat(bot): add bot commands

This commit is contained in:
2025-09-27 17:49:54 +03:00
parent e24e7c6d10
commit 023acee68f
21 changed files with 543 additions and 19 deletions

View File

@@ -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

View File

@@ -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
{

View File

@@ -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;

View File

@@ -0,0 +1,15 @@
<?php
namespace Openguru\OpenCartFramework\Telegram\Commands;
class ChatIdCommand extends TelegramCommand
{
public function handle(array $update): void
{
$chatId = $update['message']['chat']['id'];
$message = sprintf('Chat ID: %s', $chatId);
$this->telegram->sendMessage($chatId, $message);
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace Openguru\OpenCartFramework\Telegram\Commands;
use Openguru\OpenCartFramework\Telegram\Contracts\TelegramCommandInterface;
use Openguru\OpenCartFramework\Telegram\TelegramBotStateManager;
use Openguru\OpenCartFramework\Telegram\TelegramService;
abstract class TelegramCommand implements TelegramCommandInterface
{
protected TelegramService $telegram;
protected TelegramBotStateManager $state;
public function __construct(TelegramService $telegram, TelegramBotStateManager $stateManager)
{
$this->telegram = $telegram;
$this->state = $stateManager;
}
}

View File

@@ -0,0 +1,8 @@
<?php
namespace Openguru\OpenCartFramework\Telegram\Contracts;
interface TelegramCommandInterface
{
public function handle(array $update): void;
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Openguru\OpenCartFramework\Telegram\Enums;
class ChatAction
{
public const TYPING = 'typing';
public const UPLOAD_PHOTO = 'upload_photo';
public const RECORD_VIDEO = 'record_video';
public const UPLOAD_VIDEO = 'upload_video';
public const RECORD_VOICE = 'record_voice';
public const UPLOAD_VOICE = 'upload_voice';
public const UPLOAD_DOCUMENT = 'upload_document';
public const CHOOSE_STICKER = 'choose_sticker';
public const FIND_LOCATION = 'find_location';
public const RECORD_VIDEO_NOTE = 'record_video_note';
public const UPLOAD_VIDEO_NOTE = 'upload_video_note';
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Openguru\OpenCartFramework\Telegram;
namespace Openguru\OpenCartFramework\Telegram\Exceptions;
use Exception;
use Throwable;

View File

@@ -0,0 +1,16 @@
<?php
namespace Openguru\OpenCartFramework\Telegram\Exceptions;
use Exception;
use Throwable;
class TelegramCommandNotFoundException extends Exception
{
public function __construct($command = "", $code = 0, Throwable $previous = null)
{
$message = "Telegram command `$command` not found";
parent::__construct($message, $code, $previous);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace Openguru\OpenCartFramework\Telegram;
namespace Openguru\OpenCartFramework\Telegram\Exceptions;
use RuntimeException;

View File

@@ -3,6 +3,7 @@
namespace Openguru\OpenCartFramework\Telegram;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
class SignatureValidator
{

View File

@@ -0,0 +1,54 @@
<?php
namespace Openguru\OpenCartFramework\Telegram;
use Openguru\OpenCartFramework\Cache\CacheInterface;
class TelegramBotStateManager
{
private CacheInterface $cache;
public function __construct(CacheInterface $cache)
{
$this->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'];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Openguru\OpenCartFramework\Telegram;
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramCommandNotFoundException;
class TelegramCommandsRegistry
{
private array $commands = [];
public function addCommand(string $command, string $handler, ?string $description = null): void
{
$this->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'];
}
}

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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 = [])
{

View File

@@ -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

View File

@@ -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([]);
}

View File

@@ -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 сообщений с кнопкой');
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Telegram;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\GuzzleException;
use JsonException;
use Openguru\OpenCartFramework\Logger\LoggerInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\Commands\TelegramCommand;
use Openguru\OpenCartFramework\Telegram\Enums\ChatAction;
use Openguru\OpenCartFramework\Telegram\TelegramBotStateManager;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Throwable;
class LinkCommand extends TelegramCommand
{
private LoggerInterface $logger;
public function __construct(
TelegramService $telegram,
TelegramBotStateManager $stateManager,
LoggerInterface $logger
) {
parent::__construct($telegram, $stateManager);
$this->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(
<<<MARKDOWN
Это удобный инструмент, который поможет вам 📎 создать красивое сообщение с кнопкой для открытия вашего 🛒 Telecart магазина.
📌 Такое сообщение можно закрепить в канале или группе.
📤 Переслать клиентам в личные сообщения.
🚀 Или использовать повторно, когда нужно поделиться магазином.
Давайте начнём — отправьте текст, который вы хотите разместить в сообщении 👇
MARKDOWN
);
$this->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 = <<<MARKDOWN
🔸 Отлично\!
Теперь укажите, какой текст будет на кнопке 👇
✍️ Напишите короткую, понятную фразу, например:
• `Открыть магазин`
• `Каталог товаров`
• `Начать покупки`
MARKDOWN;
$this->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 = <<<MARKDOWN
🌐 Теперь отправьте *ссылку на Telegram Mini App*\.
Ссылка должна начинаться с `https://`
📎 Инструкция, где взять ссылку:
👉 {LINK}
MARKDOWN;
$text = $this->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, 'Произошла ошибка');
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace Telegram;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Tests\TestCase;
class TelegramServiceTest extends TestCase
{
private TelegramService $service;
protected function setUp(): void
{
parent::setUp();
$this->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#'));
}
}