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:
2025-11-23 16:59:30 +03:00
committed by Nikita Kiselev
parent 6a59dcc0c9
commit 9a93cc7342
34 changed files with 3245 additions and 66 deletions

View File

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

View File

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

View File

@@ -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'],
];

View File

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

View File

@@ -0,0 +1,8 @@
<?php
namespace Openguru\OpenCartFramework\Telegram\Enums;
final class TelegramHeader
{
public const INIT_DATA = 'X-Telegram-Initdata';
}

View File

@@ -0,0 +1,9 @@
<?php
namespace Openguru\OpenCartFramework\Telegram\Exceptions;
use Exception;
class DecodeTelegramInitDataException extends Exception
{
}

View File

@@ -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']);

View File

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

View File

@@ -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'),
);
});

View File

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

View File

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

View File

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

View File

@@ -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'),
]);
}
}

View File

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

View File

@@ -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'],
];