feat: add Telegram customers management system with admin panel
Implement comprehensive Telegram customers storage and management functionality: Backend: - Add database migration for telecart_customers table with indexes - Create TelegramCustomer model with CRUD operations - Implement TelegramCustomerService for business logic - Add TelegramCustomerHandler for API endpoint (saveOrUpdate) - Add TelegramCustomersHandler for admin API (getCustomers with pagination, filtering, sorting) - Add SendMessageHandler for sending messages to customers via Telegram - Create custom exceptions: TelegramCustomerNotFoundException, TelegramCustomerWriteNotAllowedException - Refactor TelegramInitDataDecoder to separate decoding logic - Add TelegramHeader enum for header constants - Update SignatureValidator to use TelegramInitDataDecoder - Register new routes in bastion/routes.php and src/routes.php Frontend (Admin): - Add CustomersView.vue component with PrimeVue DataTable - Implement advanced filtering (text, date, boolean filters) - Add column visibility toggle functionality - Add global search with debounce - Implement message sending dialog with validation - Add Russian locale for PrimeVue components - Add navigation link in App.vue - Register route in router Frontend (SPA): - Add saveTelegramCustomer utility function - Integrate automatic customer data saving on app initialization - Extract user data from Telegram.WebApp.initDataUnsafe The system automatically saves/updates customer data when users access the Telegram Mini App, and provides admin interface for viewing, filtering, and messaging customers. BREAKING CHANGE: None
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use App\Exceptions\TelegramCustomerNotFoundException;
|
||||
use App\Exceptions\TelegramCustomerWriteNotAllowedException;
|
||||
use App\Models\TelegramCustomer;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramService;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Handler для отправки сообщений Telegram-пользователям из админ-панели
|
||||
*
|
||||
* @package Bastion\Handlers
|
||||
*/
|
||||
class SendMessageHandler
|
||||
{
|
||||
private TelegramService $telegramService;
|
||||
private TelegramCustomer $telegramCustomerModel;
|
||||
private LoggerInterface $logger;
|
||||
|
||||
public function __construct(
|
||||
TelegramService $telegramService,
|
||||
TelegramCustomer $telegramCustomerModel,
|
||||
LoggerInterface $logger
|
||||
) {
|
||||
$this->telegramService = $telegramService;
|
||||
$this->telegramCustomerModel = $telegramCustomerModel;
|
||||
$this->logger = $logger;
|
||||
}
|
||||
|
||||
/**
|
||||
* Отправить сообщение Telegram-пользователю
|
||||
*
|
||||
* @param Request $request HTTP запрос с id (ID записи в таблице) и message
|
||||
* @return JsonResponse JSON ответ с результатом операции
|
||||
* @throws TelegramCustomerNotFoundException Если пользователь не найден
|
||||
* @throws TelegramCustomerWriteNotAllowedException Если пользователь не разрешил писать в PM
|
||||
* @throws RuntimeException Если данные невалидны
|
||||
* @throws \Exception
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function sendMessage(Request $request): JsonResponse
|
||||
{
|
||||
$customerId = $this->extractCustomerId($request);
|
||||
$message = $this->extractMessage($request);
|
||||
|
||||
// Находим запись по ID
|
||||
$customer = $this->telegramCustomerModel->findById($customerId);
|
||||
if (! $customer) {
|
||||
throw new TelegramCustomerNotFoundException($customerId);
|
||||
}
|
||||
|
||||
$telegramUserId = (int) $customer['telegram_user_id'];
|
||||
|
||||
// Проверяем, что пользователь разрешил писать ему в PM
|
||||
if (! $customer['allows_write_to_pm']) {
|
||||
throw new TelegramCustomerWriteNotAllowedException($telegramUserId);
|
||||
}
|
||||
|
||||
// Отправляем сообщение (telegram_user_id используется как chat_id)
|
||||
// Используем пустую строку для parse_mode чтобы отправлять обычный текст
|
||||
$this->telegramService->sendMessage(
|
||||
$telegramUserId,
|
||||
$message,
|
||||
[],
|
||||
\Openguru\OpenCartFramework\Telegram\Enums\ChatAction::TYPING,
|
||||
'' // Обычный текст без форматирования
|
||||
);
|
||||
|
||||
$this->logger->info('Message sent to Telegram user', [
|
||||
'oc_customer_id' => $customerId,
|
||||
'telegram_user_id' => $telegramUserId,
|
||||
'message_length' => strlen($message),
|
||||
]);
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'message' => 'Message sent successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь ID записи из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return int ID записи в таблице telecart_customers
|
||||
* @throws RuntimeException Если ID отсутствует или невалиден
|
||||
*/
|
||||
private function extractCustomerId(Request $request): int
|
||||
{
|
||||
$jsonData = $request->json();
|
||||
$customerId = isset($jsonData['id']) ? (int) $jsonData['id'] : 0;
|
||||
|
||||
if ($customerId <= 0) {
|
||||
throw new RuntimeException('Customer ID is required and must be positive');
|
||||
}
|
||||
|
||||
return $customerId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь сообщение из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return string Текст сообщения
|
||||
* @throws RuntimeException Если сообщение отсутствует или пустое
|
||||
*/
|
||||
private function extractMessage(Request $request): string
|
||||
{
|
||||
$jsonData = $request->json();
|
||||
$message = isset($jsonData['message']) ? trim($jsonData['message']) : '';
|
||||
|
||||
if (empty($message)) {
|
||||
throw new RuntimeException('Message is required and cannot be empty');
|
||||
}
|
||||
|
||||
return $message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Bastion\Handlers;
|
||||
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\RawExpression;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
class TelegramCustomersHandler
|
||||
{
|
||||
private const TABLE_NAME = 'telecart_customers';
|
||||
private const DEFAULT_PAGE = 1;
|
||||
private const DEFAULT_ROWS = 20;
|
||||
private const DEFAULT_SORT_FIELD = 'last_seen_at';
|
||||
private const DEFAULT_SORT_ORDER = 'DESC';
|
||||
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(Builder $builder)
|
||||
{
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список Telegram-кастомеров с пагинацией, фильтрацией и сортировкой
|
||||
*
|
||||
* @param Request $request HTTP запрос с параметрами пагинации, сортировки и фильтров
|
||||
* @return JsonResponse JSON ответ с данными и метаинформацией
|
||||
*/
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->json('page', self::DEFAULT_PAGE));
|
||||
$rows = max(1, (int) $request->json('rows', self::DEFAULT_ROWS));
|
||||
$first = ($page - 1) * $rows;
|
||||
|
||||
$sortField = $request->json('sortField', self::DEFAULT_SORT_FIELD) ?? self::DEFAULT_SORT_FIELD;
|
||||
$sortOrder = $this->normalizeSortOrder((string)$request->json('sortOrder', self::DEFAULT_SORT_ORDER));
|
||||
|
||||
$filters = $request->json('filters', []);
|
||||
$globalFilter = Arr::get($filters, 'global.value');
|
||||
|
||||
// Создаем базовый query с фильтрами
|
||||
$query = $this->buildBaseQuery();
|
||||
$this->applyFilters($query, $filters, $globalFilter);
|
||||
|
||||
// Получаем общее количество записей
|
||||
$countQuery = $this->buildCountQuery();
|
||||
$this->applyFilters($countQuery, $filters, $globalFilter);
|
||||
$totalRecords = (int) ($countQuery->value('total') ?? 0);
|
||||
|
||||
// Применяем сортировку и пагинацию
|
||||
$customers = $query
|
||||
->orderBy($sortField, $sortOrder)
|
||||
->offset($first)
|
||||
->limit($rows)
|
||||
->get();
|
||||
|
||||
return new JsonResponse([
|
||||
'data' => [
|
||||
'data' => $this->mapToResponse($customers),
|
||||
'totalRecords' => $totalRecords,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать базовый query для выборки данных
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
private function buildBaseQuery(): Builder
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->select([
|
||||
'id',
|
||||
'telegram_user_id',
|
||||
'oc_customer_id',
|
||||
'username',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'language_code',
|
||||
'is_premium',
|
||||
'allows_write_to_pm',
|
||||
'photo_url',
|
||||
'last_seen_at',
|
||||
'referral',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
])
|
||||
->from(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать query для подсчета общего количества записей
|
||||
*
|
||||
* @return Builder
|
||||
*/
|
||||
private function buildCountQuery(): Builder
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->select([new RawExpression('COUNT(*) as total')])
|
||||
->from(self::TABLE_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтры к query
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param array $filters Массив фильтров
|
||||
* @param string|null $globalFilter Глобальный фильтр поиска
|
||||
* @return void
|
||||
*/
|
||||
private function applyFilters(Builder $query, array $filters, ?string $globalFilter): void
|
||||
{
|
||||
// Применяем глобальный фильтр
|
||||
if ($globalFilter) {
|
||||
$this->applyGlobalFilter($query, $globalFilter);
|
||||
}
|
||||
|
||||
// Применяем фильтры по колонкам
|
||||
$this->applyColumnFilters($query, $filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить глобальный фильтр поиска
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $searchTerm Поисковый запрос
|
||||
* @return void
|
||||
*/
|
||||
private function applyGlobalFilter(Builder $query, string $searchTerm): void
|
||||
{
|
||||
$query->whereNested(function ($q) use ($searchTerm) {
|
||||
$q->where('telegram_user_id', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('username', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('first_name', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('last_name', 'LIKE', "%{$searchTerm}%")
|
||||
->orWhere('language_code', 'LIKE', "%{$searchTerm}%");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтры по колонкам
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param array $filters Массив фильтров
|
||||
* @return void
|
||||
*/
|
||||
private function applyColumnFilters(Builder $query, array $filters): void
|
||||
{
|
||||
foreach ($filters as $field => $filter) {
|
||||
if ($field === 'global') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обработка сложных фильтров (constraints)
|
||||
if (isset($filter['constraints']) && is_array($filter['constraints'])) {
|
||||
$this->applyConstraintFilters($query, $field, $filter);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обработка простых фильтров (обратная совместимость)
|
||||
if (! isset($filter['value']) || $filter['value'] === null || $filter['value'] === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $filter['value'];
|
||||
$matchMode = Arr::get($filter, 'matchMode', 'contains');
|
||||
|
||||
$this->applyColumnFilter($query, $field, $value, $matchMode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить сложные фильтры с условиями (AND/OR)
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $field Имя поля
|
||||
* @param array $filter Данные фильтра
|
||||
* @return void
|
||||
*/
|
||||
private function applyConstraintFilters(Builder $query, string $field, array $filter): void
|
||||
{
|
||||
$operator = strtolower($filter['operator'] ?? 'and');
|
||||
$constraints = $filter['constraints'];
|
||||
|
||||
// Фильтруем пустые значения (но учитываем false как валидное значение для boolean полей)
|
||||
$activeConstraints = array_filter($constraints, function ($constraint) {
|
||||
if (!isset($constraint['value'])) {
|
||||
return false;
|
||||
}
|
||||
$value = $constraint['value'];
|
||||
// null означает "любой", пропускаем
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
// Пустая строка пропускаем
|
||||
if ($value === '') {
|
||||
return false;
|
||||
}
|
||||
// false - валидное значение для boolean полей
|
||||
return true;
|
||||
});
|
||||
|
||||
if (empty($activeConstraints)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$query->whereNested(function ($q) use ($field, $activeConstraints, $operator) {
|
||||
// Для первого элемента всегда используем where, чтобы начать группу
|
||||
$first = true;
|
||||
|
||||
foreach ($activeConstraints as $constraint) {
|
||||
$value = $constraint['value'];
|
||||
$matchMode = $constraint['matchMode'] ?? 'contains';
|
||||
|
||||
if ($first) {
|
||||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||
$first = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operator === 'or') {
|
||||
$q->orWhere(function ($subQ) use ($field, $value, $matchMode) {
|
||||
$this->applyColumnFilter($subQ, $field, $value, $matchMode);
|
||||
});
|
||||
} else {
|
||||
$this->applyColumnFilter($q, $field, $value, $matchMode);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Применить фильтр для одной колонки
|
||||
*
|
||||
* @param Builder $query Query builder
|
||||
* @param string $field Имя поля
|
||||
* @param mixed $value Значение фильтра
|
||||
* @param string $matchMode Режим совпадения (contains, startsWith, endsWith, equals, notEquals)
|
||||
* @return void
|
||||
*/
|
||||
private function applyColumnFilter(Builder $query, string $field, $value, string $matchMode): void
|
||||
{
|
||||
if (in_array($matchMode, ['contains', 'startsWith', 'endsWith'], true)) {
|
||||
$likeValue = $this->buildLikeValue($value, $matchMode);
|
||||
$query->where($field, 'LIKE', $likeValue);
|
||||
} elseif ($matchMode === 'equals') {
|
||||
$query->where($field, '=', $value);
|
||||
} elseif ($matchMode === 'notEquals') {
|
||||
$query->where($field, '!=', $value);
|
||||
} elseif ($matchMode === 'dateIs') {
|
||||
// Для точного совпадения даты используем диапазон от 00:00:00 до 23:59:59
|
||||
$date = date('Y-m-d', strtotime($value));
|
||||
$query->where($field, '>=', $date . ' 00:00:00')
|
||||
->where($field, '<=', $date . ' 23:59:59');
|
||||
} elseif ($matchMode === 'dateIsNot') {
|
||||
// Для отрицания проверяем, что дата меньше начала дня ИЛИ больше конца дня
|
||||
$date = date('Y-m-d', strtotime($value));
|
||||
$query->whereNested(function ($q) use ($field, $date) {
|
||||
$q->where($field, '<', $date . ' 00:00:00')
|
||||
->orWhere($field, '>', $date . ' 23:59:59');
|
||||
});
|
||||
} elseif ($matchMode === 'dateBefore') {
|
||||
$query->where($field, '<', date('Y-m-d 00:00:00', strtotime($value)));
|
||||
} elseif ($matchMode === 'dateAfter') {
|
||||
// "После" означает после конца указанного дня
|
||||
$query->where($field, '>', date('Y-m-d 23:59:59', strtotime($value)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Построить значение для LIKE запроса
|
||||
*
|
||||
* @param string $value Значение
|
||||
* @param string $matchMode Режим совпадения
|
||||
* @return string
|
||||
*/
|
||||
private function buildLikeValue(string $value, string $matchMode): string
|
||||
{
|
||||
if ($matchMode === 'startsWith') {
|
||||
return "{$value}%";
|
||||
}
|
||||
|
||||
if ($matchMode === 'endsWith') {
|
||||
return "%{$value}";
|
||||
}
|
||||
|
||||
return "%{$value}%";
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализовать порядок сортировки
|
||||
*
|
||||
* @param string $sortOrder Порядок сортировки
|
||||
* @return string 'ASC' или 'DESC'
|
||||
*/
|
||||
private function normalizeSortOrder(string $sortOrder): string
|
||||
{
|
||||
$normalized = strtoupper($sortOrder);
|
||||
|
||||
return in_array($normalized, ['ASC', 'DESC'], true) ? $normalized : self::DEFAULT_SORT_ORDER;
|
||||
}
|
||||
|
||||
private function mapToResponse(array $customers): array
|
||||
{
|
||||
return array_map(static function (array $customer) {
|
||||
return [
|
||||
'id' => (int) $customer['id'],
|
||||
'telegram_user_id' => (int) $customer['telegram_user_id'],
|
||||
'oc_customer_id' => (int) $customer['oc_customer_id'],
|
||||
'username' => $customer['username'],
|
||||
'first_name' => $customer['first_name'],
|
||||
'last_name' => $customer['last_name'],
|
||||
'language_code' => $customer['language_code'],
|
||||
'is_premium' => filter_var($customer['is_premium'], FILTER_VALIDATE_BOOLEAN),
|
||||
'allows_write_to_pm' => filter_var($customer['allows_write_to_pm'], FILTER_VALIDATE_BOOLEAN),
|
||||
'photo_url' => $customer['photo_url'],
|
||||
'last_seen_at' => $customer['last_seen_at'],
|
||||
'referral' => $customer['referral'],
|
||||
'created_at' => $customer['created_at'],
|
||||
'updated_at' => $customer['updated_at'],
|
||||
];
|
||||
}, $customers);
|
||||
}
|
||||
}
|
||||
@@ -4,27 +4,28 @@ use Bastion\Handlers\AutocompleteHandler;
|
||||
use Bastion\Handlers\DictionariesHandler;
|
||||
use Bastion\Handlers\FormsHandler;
|
||||
use Bastion\Handlers\LogsHandler;
|
||||
use Bastion\Handlers\SendMessageHandler;
|
||||
use Bastion\Handlers\SettingsHandler;
|
||||
use Bastion\Handlers\StatsHandler;
|
||||
use Bastion\Handlers\TelegramCustomersHandler;
|
||||
use Bastion\Handlers\TelegramHandler;
|
||||
|
||||
return [
|
||||
'configureBotToken' => [SettingsHandler::class, 'configureBotToken'],
|
||||
'getChatId' => [TelegramHandler::class, 'getChatId'],
|
||||
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
|
||||
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
|
||||
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
|
||||
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
|
||||
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
|
||||
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
|
||||
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
|
||||
|
||||
'getCategories' => [DictionariesHandler::class, 'getCategories'],
|
||||
|
||||
'getAutocompleteCategories' => [AutocompleteHandler::class, 'getCategories'],
|
||||
'getAutocompleteCategoriesFlat' => [AutocompleteHandler::class, 'getCategoriesFlat'],
|
||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||
'getLogs' => [LogsHandler::class, 'getLogs'],
|
||||
|
||||
'getCategories' => [DictionariesHandler::class, 'getCategories'],
|
||||
'getCategoriesById' => [AutocompleteHandler::class, 'getCategoriesById'],
|
||||
'getChatId' => [TelegramHandler::class, 'getChatId'],
|
||||
'getDashboardStats' => [StatsHandler::class, 'getDashboardStats'],
|
||||
'getFormByAlias' => [FormsHandler::class, 'getFormByAlias'],
|
||||
'getLogs' => [LogsHandler::class, 'getLogs'],
|
||||
'getProductsById' => [AutocompleteHandler::class, 'getProductsById'],
|
||||
'getSettingsForm' => [SettingsHandler::class, 'getSettingsForm'],
|
||||
'getTelegramCustomers' => [TelegramCustomersHandler::class, 'getCustomers'],
|
||||
'resetCache' => [SettingsHandler::class, 'resetCache'],
|
||||
'saveSettingsForm' => [SettingsHandler::class, 'saveSettingsForm'],
|
||||
'sendMessageToCustomer' => [SendMessageHandler::class, 'sendMessage'],
|
||||
'testTgMessage' => [TelegramHandler::class, 'testTgMessage'],
|
||||
'tgGetMe' => [TelegramHandler::class, 'tgGetMe'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Openguru\OpenCartFramework\Migrations\Migration;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$tableName = 'telecart_customers';
|
||||
|
||||
$sql = <<<SQL
|
||||
CREATE TABLE IF NOT EXISTS `{$tableName}` (
|
||||
`id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`telegram_user_id` BIGINT(20) UNSIGNED NOT NULL,
|
||||
`oc_customer_id` INT(11) UNSIGNED DEFAULT NULL,
|
||||
`username` VARCHAR(255) DEFAULT NULL,
|
||||
`first_name` VARCHAR(255) DEFAULT NULL,
|
||||
`last_name` VARCHAR(255) DEFAULT NULL,
|
||||
`language_code` VARCHAR(10) DEFAULT NULL,
|
||||
`is_premium` TINYINT(1) UNSIGNED DEFAULT 0,
|
||||
`allows_write_to_pm` TINYINT(1) UNSIGNED DEFAULT 0,
|
||||
`photo_url` VARCHAR(512) DEFAULT NULL,
|
||||
`last_seen_at` DATETIME DEFAULT NULL,
|
||||
`referral` VARCHAR(255) DEFAULT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `unique_telegram_user_id` (`telegram_user_id`),
|
||||
KEY `idx_oc_customer_id` (`oc_customer_id`),
|
||||
KEY `idx_last_seen_at` (`last_seen_at`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB;
|
||||
SQL;
|
||||
|
||||
$this->database->statement($sql);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace Openguru\OpenCartFramework\Telegram\Enums;
|
||||
|
||||
final class TelegramHeader
|
||||
{
|
||||
public const INIT_DATA = 'X-Telegram-Initdata';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace Openguru\OpenCartFramework\Telegram\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class DecodeTelegramInitDataException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -4,22 +4,33 @@ namespace Openguru\OpenCartFramework\Telegram;
|
||||
|
||||
use App\Services\SettingsService;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\TelegramInvalidSignatureException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class SignatureValidator
|
||||
{
|
||||
private ?string $botToken;
|
||||
private SettingsService $settings;
|
||||
private LoggerInterface $logger;
|
||||
private TelegramInitDataDecoder $initDataDecoder;
|
||||
|
||||
public function __construct(SettingsService $settings, LoggerInterface $logger, ?string $botToken = null)
|
||||
{
|
||||
public function __construct(
|
||||
SettingsService $settings,
|
||||
LoggerInterface $logger,
|
||||
TelegramInitDataDecoder $initDataDecoder,
|
||||
?string $botToken = null
|
||||
) {
|
||||
$this->botToken = $botToken;
|
||||
$this->settings = $settings;
|
||||
$this->logger = $logger;
|
||||
$this->initDataDecoder = $initDataDecoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws TelegramInvalidSignatureException
|
||||
*/
|
||||
public function validate(Request $request): void
|
||||
{
|
||||
if ($this->settings->config()->getApp()->isAppDebug()) {
|
||||
@@ -32,13 +43,15 @@ class SignatureValidator
|
||||
return;
|
||||
}
|
||||
|
||||
$initDataString = base64_decode($request->header('X-Telegram-Initdata'));
|
||||
|
||||
if (! $initDataString) {
|
||||
throw new TelegramInvalidSignatureException('Invalid Telegram signature!');
|
||||
if (! $request->header(TelegramHeader::INIT_DATA)) {
|
||||
throw new TelegramInvalidSignatureException('Telegram Signature not exists.');
|
||||
}
|
||||
|
||||
$data = $this->parseInitDataStringToArray($initDataString);
|
||||
try {
|
||||
$data = $this->initDataDecoder->decode($request->header(TelegramHeader::INIT_DATA));
|
||||
} catch (DecodeTelegramInitDataException $e) {
|
||||
throw new TelegramInvalidSignatureException('Invalid Telegram Signature.', 500, $e);
|
||||
}
|
||||
|
||||
if (! isset($data['hash'])) {
|
||||
throw new TelegramInvalidSignatureException('Missing hash in init data');
|
||||
@@ -55,26 +68,6 @@ class SignatureValidator
|
||||
}
|
||||
}
|
||||
|
||||
private function parseInitDataStringToArray(string $initData): array
|
||||
{
|
||||
parse_str($initData, $parsed);
|
||||
|
||||
foreach ($parsed as $key => $value) {
|
||||
if ($this->isValidJson($value)) {
|
||||
$parsed[$key] = json_decode(urldecode($value), true);
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isValidJson(string $jsonString): bool
|
||||
{
|
||||
json_decode($jsonString);
|
||||
|
||||
return (json_last_error() === JSON_ERROR_NONE);
|
||||
}
|
||||
|
||||
private function getCheckString(array $data): string
|
||||
{
|
||||
unset($data['hash']);
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Openguru\OpenCartFramework\Telegram;
|
||||
|
||||
use JsonException;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||
|
||||
class TelegramInitDataDecoder
|
||||
{
|
||||
/**
|
||||
* @throws DecodeTelegramInitDataException
|
||||
*/
|
||||
public function decode(string $initDataDecoded): array
|
||||
{
|
||||
$initDataString = base64_decode($initDataDecoded);
|
||||
|
||||
if ($initDataString === false) {
|
||||
throw new DecodeTelegramInitDataException('Could not decode init data.');
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->parseInitDataStringToArray($initDataString);
|
||||
} catch (JsonException $e) {
|
||||
throw new DecodeTelegramInitDataException(
|
||||
'Error decoding Telegram init data JSON: ' . $e->getMessage(),
|
||||
500,
|
||||
$e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws JsonException
|
||||
*/
|
||||
private function parseInitDataStringToArray(string $initData): array
|
||||
{
|
||||
parse_str($initData, $parsed);
|
||||
|
||||
foreach ($parsed as $key => $value) {
|
||||
if ($this->isValidJson($value)) {
|
||||
$parsed[$key] = json_decode(urldecode($value), true, 512, JSON_THROW_ON_ERROR);
|
||||
} else {
|
||||
$parsed[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private function isValidJson(string $string): bool
|
||||
{
|
||||
try {
|
||||
json_decode($string, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return true;
|
||||
} catch (JsonException $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ class TelegramServiceProvider extends ServiceProvider
|
||||
return new SignatureValidator(
|
||||
$app->get(SettingsService::class),
|
||||
$app->get(LoggerInterface::class),
|
||||
$app->get(TelegramInitDataDecoder::class),
|
||||
$app->getConfigValue('telegram.bot_token'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Исключение, выбрасываемое когда Telegram-кастомер не найден
|
||||
*
|
||||
* @package App\Exceptions
|
||||
*/
|
||||
class TelegramCustomerNotFoundException extends RuntimeException
|
||||
{
|
||||
public function __construct(int $customerId, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct(
|
||||
"Telegram customer with record ID {$customerId} not found",
|
||||
404,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Исключение, выбрасываемое когда пользователь не разрешил писать ему в PM
|
||||
*
|
||||
* @package App\Exceptions
|
||||
*/
|
||||
class TelegramCustomerWriteNotAllowedException extends RuntimeException
|
||||
{
|
||||
public function __construct(int $telegramUserId, ?\Throwable $previous = null)
|
||||
{
|
||||
parent::__construct(
|
||||
"User {$telegramUserId} has not allowed writing to PM",
|
||||
400,
|
||||
$previous
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Handlers;
|
||||
|
||||
use App\Services\TelegramCustomerService;
|
||||
use Openguru\OpenCartFramework\Http\JsonResponse;
|
||||
use Openguru\OpenCartFramework\Http\Request;
|
||||
use Openguru\OpenCartFramework\Http\Response;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use Openguru\OpenCartFramework\Telegram\Enums\TelegramHeader;
|
||||
use Openguru\OpenCartFramework\Telegram\Exceptions\DecodeTelegramInitDataException;
|
||||
use Openguru\OpenCartFramework\Telegram\TelegramInitDataDecoder;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class TelegramCustomerHandler
|
||||
{
|
||||
private TelegramCustomerService $telegramCustomerService;
|
||||
private LoggerInterface $logger;
|
||||
private TelegramInitDataDecoder $initDataDecoder;
|
||||
|
||||
public function __construct(
|
||||
TelegramCustomerService $telegramCustomerService,
|
||||
LoggerInterface $logger,
|
||||
TelegramInitDataDecoder $initDataDecoder
|
||||
) {
|
||||
$this->telegramCustomerService = $telegramCustomerService;
|
||||
$this->logger = $logger;
|
||||
$this->initDataDecoder = $initDataDecoder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить или обновить Telegram-пользователя
|
||||
*
|
||||
* @param Request $request HTTP запрос с данными пользователя
|
||||
* @return JsonResponse JSON ответ с результатом операции
|
||||
*/
|
||||
public function saveOrUpdate(Request $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->telegramCustomerService->saveOrUpdate(
|
||||
$this->extractTelegramUserData($request)
|
||||
);
|
||||
|
||||
return new JsonResponse([], Response::HTTP_NO_CONTENT);
|
||||
} catch (Throwable $e) {
|
||||
$this->logger->error('Could not save telegram customer data', ['exception' => $e]);
|
||||
|
||||
return new JsonResponse([], Response::HTTP_INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь данные Telegram пользователя из запроса
|
||||
*
|
||||
* @param Request $request HTTP запрос
|
||||
* @return array Данные пользователя
|
||||
* @throws RuntimeException|DecodeTelegramInitDataException невозможно извлечь данные пользователя из Request
|
||||
*/
|
||||
private function extractTelegramUserData(Request $request): array
|
||||
{
|
||||
$telegramUserData = $request->json('user');
|
||||
|
||||
if (! $telegramUserData) {
|
||||
$telegramUserData = $this->extractUserDataFromInitData($request);
|
||||
}
|
||||
|
||||
return $telegramUserData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws DecodeTelegramInitDataException
|
||||
*/
|
||||
private function extractUserDataFromInitData(Request $request): array
|
||||
{
|
||||
$raw = $request->header(TelegramHeader::INIT_DATA);
|
||||
if (! $raw) {
|
||||
throw new RuntimeException('No init data found in http request header');
|
||||
}
|
||||
|
||||
$initData = $this->initDataDecoder->decode($raw);
|
||||
|
||||
return Arr::get($initData, 'user');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Builder;
|
||||
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
|
||||
use RuntimeException;
|
||||
|
||||
class TelegramCustomer
|
||||
{
|
||||
private const TABLE_NAME = 'telecart_customers';
|
||||
|
||||
private ConnectionInterface $database;
|
||||
private Builder $builder;
|
||||
|
||||
public function __construct(ConnectionInterface $database, Builder $builder)
|
||||
{
|
||||
$this->database = $database;
|
||||
$this->builder = $builder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти запись по ID
|
||||
*
|
||||
* @param int $id ID записи
|
||||
* @return array|null Данные пользователя или null если не найдено
|
||||
*/
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
return $this->builder
|
||||
->newQuery()
|
||||
->select(['*'])
|
||||
->from(self::TABLE_NAME)
|
||||
->where('id', '=', $id)
|
||||
->firstOrNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти запись по Telegram user ID
|
||||
*
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @return array|null Данные пользователя или null если не найдено
|
||||
*/
|
||||
public function findByTelegramUserId(int $telegramUserId): ?array
|
||||
{
|
||||
return $this->builder
|
||||
->newQuery()
|
||||
->select(['*'])
|
||||
->from(self::TABLE_NAME)
|
||||
->where('telegram_user_id', '=', $telegramUserId)
|
||||
->firstOrNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Найти запись по oc_customer_id
|
||||
*
|
||||
* @param int $customerId ID покупателя в OpenCart
|
||||
* @return array|null Данные пользователя или null если не найдено
|
||||
*/
|
||||
public function findByCustomerId(int $customerId): ?array
|
||||
{
|
||||
return $this->builder
|
||||
->newQuery()
|
||||
->select(['*'])
|
||||
->from(self::TABLE_NAME)
|
||||
->where('oc_customer_id', '=', $customerId)
|
||||
->firstOrNull();
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать новую запись
|
||||
*
|
||||
* @param array $data Данные для создания записи
|
||||
* @return int ID созданной записи
|
||||
* @throws RuntimeException Если не удалось создать запись
|
||||
*/
|
||||
public function create(array $data): int
|
||||
{
|
||||
$data['created_at'] = date('Y-m-d H:i:s');
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
$success = $this->database->insert(self::TABLE_NAME, $data);
|
||||
|
||||
if (! $success) {
|
||||
$error = $this->database->getLastError();
|
||||
$errorMessage = $error ? $error[1] : 'Unknown error';
|
||||
throw new RuntimeException("Failed to insert telegram customer. Error: {$errorMessage}");
|
||||
}
|
||||
|
||||
return $this->database->lastInsertId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить запись по Telegram user ID
|
||||
*
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @param array $data Данные для обновления
|
||||
* @return bool true если обновление успешно
|
||||
*/
|
||||
public function updateByTelegramUserId(int $telegramUserId, array $data): bool
|
||||
{
|
||||
$data['updated_at'] = date('Y-m-d H:i:s');
|
||||
|
||||
return $this->builder->newQuery()
|
||||
->where('telegram_user_id', '=', $telegramUserId)
|
||||
->update(self::TABLE_NAME, $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновить last_seen_at для пользователя
|
||||
*
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @return bool true если обновление успешно
|
||||
*/
|
||||
public function updateLastSeen(int $telegramUserId): bool
|
||||
{
|
||||
return $this->updateByTelegramUserId($telegramUserId, [
|
||||
'last_seen_at' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\TelegramCustomer;
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Сервис для работы с Telegram-кастомерами
|
||||
*
|
||||
* @package App\Services
|
||||
*/
|
||||
class TelegramCustomerService
|
||||
{
|
||||
private TelegramCustomer $telegramCustomerModel;
|
||||
|
||||
public function __construct(TelegramCustomer $telegramCustomerModel)
|
||||
{
|
||||
$this->telegramCustomerModel = $telegramCustomerModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить или обновить Telegram-пользователя
|
||||
*
|
||||
* @param array $telegramUserData Данные пользователя из Telegram.WebApp.initDataUnsafe
|
||||
* @return void
|
||||
* @throws RuntimeException Если данные невалидны или не удалось сохранить
|
||||
*/
|
||||
public function saveOrUpdate(array $telegramUserData): void
|
||||
{
|
||||
$telegramUserId = $this->extractTelegramUserId($telegramUserData);
|
||||
$telegramCustomerData = $this->prepareCustomerData($telegramUserData, $telegramUserId);
|
||||
|
||||
$existingRecord = $this->telegramCustomerModel->findByTelegramUserId($telegramUserId);
|
||||
|
||||
if ($existingRecord) {
|
||||
$this->telegramCustomerModel->updateByTelegramUserId($telegramUserId, $telegramCustomerData);
|
||||
} else {
|
||||
$this->telegramCustomerModel->create($telegramCustomerData);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлечь Telegram user ID из данных
|
||||
*
|
||||
* @param array $telegramUserData Данные пользователя
|
||||
* @return int Telegram user ID
|
||||
* @throws RuntimeException Если ID отсутствует или невалиден
|
||||
*/
|
||||
private function extractTelegramUserId(array $telegramUserData): int
|
||||
{
|
||||
$telegramUserId = (int) Arr::get($telegramUserData, 'id');
|
||||
|
||||
if ($telegramUserId <= 0) {
|
||||
throw new RuntimeException('Telegram user ID is required and must be positive');
|
||||
}
|
||||
|
||||
return $telegramUserId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подготовить данные для сохранения в БД
|
||||
*
|
||||
* @param array $telegramUserData Исходные данные пользователя
|
||||
* @param int $telegramUserId Telegram user ID
|
||||
* @return array Подготовленные данные
|
||||
*/
|
||||
private function prepareCustomerData(array $telegramUserData, int $telegramUserId): array
|
||||
{
|
||||
return [
|
||||
'telegram_user_id' => $telegramUserId,
|
||||
'username' => Arr::get($telegramUserData, 'username'),
|
||||
'first_name' => Arr::get($telegramUserData, 'first_name'),
|
||||
'last_name' => Arr::get($telegramUserData, 'last_name'),
|
||||
'language_code' => Arr::get($telegramUserData, 'language_code'),
|
||||
'is_premium' => $this->convertToInt(Arr::get($telegramUserData, 'is_premium', false)),
|
||||
'allows_write_to_pm' => $this->convertToInt(Arr::get($telegramUserData, 'allows_write_to_pm', false)),
|
||||
'photo_url' => Arr::get($telegramUserData, 'photo_url'),
|
||||
'last_seen_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертировать булево значение в int для БД
|
||||
*
|
||||
* @param mixed $value Значение для конвертации
|
||||
* @return int 1 или 0
|
||||
*/
|
||||
private function convertToInt($value): int
|
||||
{
|
||||
return ($value === true || $value === 1 || $value === '1') ? 1 : 0;
|
||||
}
|
||||
}
|
||||
@@ -10,26 +10,22 @@ use App\Handlers\OrderHandler;
|
||||
use App\Handlers\ProductsHandler;
|
||||
use App\Handlers\SettingsHandler;
|
||||
use App\Handlers\TelegramHandler;
|
||||
use App\Handlers\TelegramCustomerHandler;
|
||||
|
||||
return [
|
||||
'health' => [HealthCheckHandler::class, 'handle'],
|
||||
|
||||
'products' => [ProductsHandler::class, 'index'],
|
||||
'product_show' => [ProductsHandler::class, 'show'],
|
||||
'storeOrder' => [OrderHandler::class, 'store'],
|
||||
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
||||
|
||||
'categoriesList' => [CategoriesHandler::class, 'index'],
|
||||
|
||||
'checkout' => [CartHandler::class, 'checkout'],
|
||||
'filtersForMainPage' => [FiltersHandler::class, 'getFiltersForMainPage'],
|
||||
'getCart' => [CartHandler::class, 'index'],
|
||||
|
||||
'settings' => [SettingsHandler::class, 'index'],
|
||||
'manifest' => [SettingsHandler::class, 'manifest'],
|
||||
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||
|
||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||
|
||||
'getForm' => [FormsHandler::class, 'getForm'],
|
||||
'health' => [HealthCheckHandler::class, 'handle'],
|
||||
'manifest' => [SettingsHandler::class, 'manifest'],
|
||||
'processBlock' => [BlocksHandler::class, 'processBlock'],
|
||||
'product_show' => [ProductsHandler::class, 'show'],
|
||||
'products' => [ProductsHandler::class, 'index'],
|
||||
'saveTelegramCustomer' => [TelegramCustomerHandler::class, 'saveOrUpdate'],
|
||||
'settings' => [SettingsHandler::class, 'index'],
|
||||
'storeOrder' => [OrderHandler::class, 'store'],
|
||||
'testTgMessage' => [SettingsHandler::class, 'testTgMessage'],
|
||||
'webhook' => [TelegramHandler::class, 'webhook'],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user