This commit is contained in:
2025-08-05 21:53:39 +03:00
parent 50bf9061be
commit ef137729ac
22 changed files with 221 additions and 87 deletions

View File

View File

@@ -15,6 +15,7 @@
* @property User $user * @property User $user
* @property ModelCustomerCustomerGroup $model_customer_customer_group * @property ModelCustomerCustomerGroup $model_customer_customer_group
* @property ModelLocalisationOrderStatus $model_localisation_order_status * @property ModelLocalisationOrderStatus $model_localisation_order_status
* @property DB $db
*/ */
class ControllerExtensionModuleTgshop extends Controller class ControllerExtensionModuleTgshop extends Controller
{ {

View File

@@ -161,7 +161,7 @@
/> />
<script> <script>
$('#{{ settingKey }}-btn').click(function () { $('#{{ settingKey }}-btn').click(function () {
const telegramToken = $('#module_tgshop_bot_token').val(); // fetch from input const telegramToken = $('#module_tgshop_bot_token').val().trim(); // fetch from input
if (! telegramToken) { if (! telegramToken) {
alert('Сначала введите Telegram Bot Token!'); alert('Сначала введите Telegram Bot Token!');
return; return;
@@ -251,19 +251,19 @@
</div> </div>
<script> <script>
$('#{{ settingKey }}-btn-test').click(function () { $('#{{ settingKey }}-btn-test').click(function () {
const telegramToken = $('#module_tgshop_bot_token').val(); // fetch from input const telegramToken = $('#module_tgshop_bot_token').val().trim();
if (! telegramToken) { if (! telegramToken) {
alert('Сначала введите Telegram Bot Token!'); alert('Сначала введите Telegram Bot Token!');
return; return;
} }
const chatId = $('#module_tgshop_chat_id').val(); // fetch from input const chatId = $('#module_tgshop_chat_id').val().trim();
if (! chatId) { if (! chatId) {
alert('Сначала введите Chat ID!'); alert('Сначала введите Chat ID!');
return; return;
} }
const template = $('#{{ settingKey }}').val(); const template = $('#{{ settingKey }}').val().trim();
if (! template) { if (! template) {
alert('Сначала задайте шаблон!'); alert('Сначала задайте шаблон!');
return; return;
@@ -272,7 +272,7 @@
fetch('/index.php?route=extension/tgshop/handle&api_action=testTgMessage', { fetch('/index.php?route=extension/tgshop/handle&api_action=testTgMessage', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
token: telegramToken, token: telegramToken,

View File

@@ -55,7 +55,8 @@ class Application extends Container
$dotenv->load(); $dotenv->load();
$errorHandler = new ErrorHandler( $errorHandler = new ErrorHandler(
$this->get(Logger::class) $this->get(Logger::class),
$this,
); );
$errorHandler->register(); $errorHandler->register();

View File

@@ -0,0 +1,11 @@
<?php
namespace Openguru\OpenCartFramework\Contracts;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Throwable;
interface ExceptionHandlerInterface
{
public function respond(Throwable $exception): ?JsonResponse;
}

View File

@@ -3,6 +3,7 @@
namespace Openguru\OpenCartFramework; namespace Openguru\OpenCartFramework;
use ErrorException; use ErrorException;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface; use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
use Openguru\OpenCartFramework\Http\JsonResponse; use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Response; use Openguru\OpenCartFramework\Http\Response;
@@ -15,10 +16,12 @@ use Throwable;
class ErrorHandler class ErrorHandler
{ {
private $logger; private $logger;
private Application $app;
public function __construct(Logger $logger) public function __construct(Logger $logger, Application $application)
{ {
$this->logger = $logger; $this->logger = $logger;
$this->app = $application;
} }
public function register(): void public function register(): void
@@ -42,6 +45,15 @@ class ErrorHandler
public function handleException(Throwable $exception): void public function handleException(Throwable $exception): void
{ {
if ($this->app->has(ExceptionHandlerInterface::class)) {
$customHandler = $this->app->get(ExceptionHandlerInterface::class);
$response = $customHandler->respond($exception);
if ($response !== null) {
$response->send();
return;
}
}
if (!$exception instanceof NonLoggableExceptionInterface) { if (!$exception instanceof NonLoggableExceptionInterface) {
$this->logger->logException($exception); $this->logger->logException($exception);
} }

View File

@@ -13,6 +13,7 @@ class Request
private $files; private $files;
private $server; private $server;
private $content; private $content;
private array $headers = [];
public function __construct( public function __construct(
array $query, array $query,
@@ -20,26 +21,27 @@ class Request
array $cookies, array $cookies,
array $files, array $files,
array $server, array $server,
array $headers = [],
string $content = null string $content = null
) ) {
{
$this->query = $query; $this->query = $query;
$this->request = $request; $this->request = $request;
$this->cookies = $cookies; $this->cookies = $cookies;
$this->files = $files; $this->files = $files;
$this->server = $server; $this->server = $server;
$this->headers = $headers;
$this->content = $content; $this->content = $content;
} }
public static function createFromGlobals(): Request public static function createFromGlobals(): Request
{ {
return new static($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER); return new static($_GET, $_POST, $_COOKIE, $_FILES, $_SERVER, getallheaders());
} }
public function getContent(): string public function getContent(): string
{ {
if ($this->content === null || $this->content === '') { if ($this->content === null || $this->content === '') {
$this->content = (string)file_get_contents('php://input'); $this->content = (string) file_get_contents('php://input');
} }
return $this->content; return $this->content;
@@ -49,7 +51,7 @@ class Request
{ {
$content = $this->getContent(); $content = $this->getContent();
if (!$content) { if (! $content) {
return $default; return $default;
} }
@@ -84,7 +86,7 @@ class Request
public function header(string $name): ?string public function header(string $name): ?string
{ {
$headers = []; $headers = [];
foreach (getallheaders() as $key => $value) { foreach ($this->headers as $key => $value) {
$headers[mb_strtolower($key)] = trim($value); $headers[mb_strtolower($key)] = trim($value);
} }

View File

@@ -78,4 +78,13 @@ class SignatureValidator
return implode(PHP_EOL, $array); return implode(PHP_EOL, $array);
} }
public function ensureUserWantsToReceiveMessages($request): void
{
$initDataString = rawurldecode($request->header('X-Telegram-Initdata'));
if (! $initDataString) {
throw new TelegramInvalidSignatureException('Invalid Telegram signature!');
}
}
} }

View File

@@ -3,6 +3,9 @@
namespace Openguru\OpenCartFramework\Telegram; namespace Openguru\OpenCartFramework\Telegram;
use GuzzleHttp\Client; use GuzzleHttp\Client;
use JsonException;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Logger\Logger; use Openguru\OpenCartFramework\Logger\Logger;
class TelegramService class TelegramService
@@ -18,7 +21,8 @@ class TelegramService
$this->client = $this->createGuzzleClient("https://api.telegram.org/bot{$botToken}/"); $this->client = $this->createGuzzleClient("https://api.telegram.org/bot{$botToken}/");
} }
public function escapeTelegramMarkdownV2(string $text): string { public function escapeTelegramMarkdownV2(string $text): string
{
$specials = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!']; $specials = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
foreach ($specials as $char) { foreach ($specials as $char) {
$text = str_replace($char, '\\' . $char, $text); $text = str_replace($char, '\\' . $char, $text);
@@ -33,10 +37,10 @@ class TelegramService
return str_replace(array_keys($variables), $values, $template); return str_replace(array_keys($variables), $values, $template);
} }
public function sendMessage(int $chatId, string $text): bool public function sendMessage(int $chatId, string $text): void
{ {
if (! $this->botToken) { if (! $this->botToken) {
return false; return;
} }
$query = [ $query = [
@@ -48,8 +52,6 @@ class TelegramService
$this->client->get('sendMessage', [ $this->client->get('sendMessage', [
'query' => $query, 'query' => $query,
]); ]);
return true;
} }
private function createGuzzleClient(string $uri): Client private function createGuzzleClient(string $uri): Client
@@ -66,4 +68,29 @@ class TelegramService
return $this; return $this;
} }
private function ensureUserWantsToReceiveMessages(): bool
{
/** @var Request $request */
$request = Application::getInstance()->get(Request::class);
$initDataString = $request->header('X-Telegram-Initdata');
if (! $initDataString) {
return false;
}
parse_str($initDataString, $initData);
if (! isset($initData['user'])) {
return false;
}
try {
$user = json_decode($initData['user'], true, 512, JSON_THROW_ON_ERROR);
return ! empty($user['allows_write_to_pm']);
} catch (JsonException $e) {
$this->logger->logException($e);
return false;
}
}
} }

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Exceptions;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Response;
use Openguru\OpenCartFramework\Telegram\TelegramInvalidSignatureException;
use Throwable;
class CustomExceptionHandler implements ExceptionHandlerInterface
{
public function respond(Throwable $exception): ?JsonResponse
{
if ($exception instanceof TelegramInvalidSignatureException) {
return new JsonResponse(['error' => 'Invalid Signature'], Response::HTTP_BAD_REQUEST);
}
return null;
}
}

View File

@@ -89,10 +89,22 @@ class SettingsHandler
$token = $request->json('token'); $token = $request->json('token');
$chatId = $request->json('chat_id'); $chatId = $request->json('chat_id');
if (! $token) {
return new JsonResponse([
'message' => 'Не задан Telegram BotToken',
]);
}
if (! $chatId) {
return new JsonResponse([
'message' => 'Не задан ChatID.',
]);
}
$variables = [ $variables = [
'{store_name}' => $this->settings->get('oc_store_name'), '{store_name}' => $this->settings->get('oc_store_name'),
'{order_id}' => 777, '{order_id}' => 777,
'{customer}' => 'Иван Вастльевич', '{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@opencart.com', '{email}' => 'telegram@opencart.com',
'{phone}' => '+79999999999', '{phone}' => '+79999999999',
'{comment}' => 'Это тестовый заказ', '{comment}' => 'Это тестовый заказ',
@@ -108,11 +120,13 @@ class SettingsHandler
$this->telegramService $this->telegramService
->setBotToken($token) ->setBotToken($token)
->sendMessage($chatId, $message); ->sendMessage($chatId, $message);
return new JsonResponse([ return new JsonResponse([
'message' => 'Сообщение отправлено. Проверьте Telegram.', 'message' => 'Сообщение отправлено. Проверьте Telegram.',
]); ]);
} catch (ClientException $exception) { } catch (ClientException $exception) {
$json = json_decode($exception->getResponse()->getBody(), true); $json = json_decode($exception->getResponse()->getBody(), true);
return new JsonResponse([ return new JsonResponse([
'message' => $json['description'], 'message' => $json['description'],
]); ]);

View File

@@ -2,7 +2,9 @@
namespace App\ServiceProviders; namespace App\ServiceProviders;
use App\Exceptions\CustomExceptionHandler;
use Openguru\OpenCartFramework\Container\ServiceProvider; use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\Router\Router; use Openguru\OpenCartFramework\Router\Router;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -10,5 +12,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void public function register(): void
{ {
$this->container->get(Router::class)->loadRoutesFromFile(__DIR__ . '/../routes.php'); $this->container->get(Router::class)->loadRoutesFromFile(__DIR__ . '/../routes.php');
$this->container->singleton(ExceptionHandlerInterface::class, function () {
return new CustomExceptionHandler();
});
} }
} }

View File

@@ -283,11 +283,13 @@ class CartService
$lastTotal = $totals[count($totals) - 1] ?? false; $lastTotal = $totals[count($totals) - 1] ?? false;
$data['total'] = $lastTotal ? $lastTotal['value'] : 0; $data['total'] = $lastTotal ? $lastTotal['value'] : 0;
$data['total_text'] = $lastTotal ? $this->oc->currency->format($lastTotal['value'], $this->oc->session->data['currency']) : 0;
$data['total_products_count'] = $this->oc->cart->countProducts(); $data['total_products_count'] = $this->oc->cart->countProducts();
} else { } else {
$data['text_error'] = $this->oc->language->get('text_empty'); $data['text_error'] = $this->oc->language->get('text_empty');
$data['totals'] = []; $data['totals'] = [];
$data['total'] = 0; $data['total'] = 0;
$data['total_text'] = '';
$data['products'] = []; $data['products'] = [];
$data['total_products_count'] = 0; $data['total_products_count'] = 0;
unset($this->oc->session->data['success']); unset($this->oc->session->data['success']);

View File

@@ -8,6 +8,7 @@ use Exception;
use Openguru\OpenCartFramework\Config\Settings; use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Logger\Logger; use Openguru\OpenCartFramework\Logger\Logger;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface; use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\TelegramService; use Openguru\OpenCartFramework\Telegram\TelegramService;
use Rakit\Validation\Validator; use Rakit\Validation\Validator;
use RuntimeException; use RuntimeException;
@@ -82,7 +83,7 @@ class OrderCreateService
$orderId = null; $orderId = null;
$this->database->transaction( $this->database->transaction(
function () use ($orderData, $products, $totals, $orderStatusId, $now, &$orderId) { function () use (&$orderData, $products, $totals, $orderStatusId, $now, &$orderId) {
$success = $this->database->insert(db_table('order'), $orderData); $success = $this->database->insert(db_table('order'), $orderData);
if (! $success) { if (! $success) {
@@ -163,52 +164,10 @@ class OrderCreateService
$this->cartService->flush(); $this->cartService->flush();
$chatId = $this->settings->get('telegram.chat_id'); $orderData['order_id'] = $orderId;
$template = $this->settings->get('telegram.owner_notification_template'); $orderData['total'] = $cart['total_text'] ?? '';
$variables = [
'{store_name}' => $orderData['store_name'],
'{order_id}' => $orderId,
'{customer}' => $orderData['firstname'] . ' ' . $orderData['lastname'],
'{email}' => $orderData['email'],
'{phone}' => $orderData['telephone'],
'{comment}' => $orderData['comment'],
'{address}' => $orderData['shipping_address_1'],
'{total}' => $total,
'{ip}' => $orderData['ip'],
'{created_at}' => $now,
];
if ($chatId && $template) { $this->sendNotifications($orderData, $data['tgData']);
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($chatId, $message);
} catch (Exception $exception) {
$this->logger->error(
'Telegram sendMessage error: ' . json_encode([
'chat_id' => $chatId,
'text' => $message,
])
);
$this->logger->logException($exception);
}
}
$customerChatId = $data['tgData']['id'] ?? null;
$template = $this->settings->get('telegram.customer_notification_template');
if ($customerChatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($customerChatId, $message);
} catch (Exception $exception) {
$this->logger->error(
'Telegram sendMessage error: ' . json_encode([
'chat_id' => $chatId,
'text' => $message,
])
);
$this->logger->logException($exception);
}
}
} }
private function validate(array $data): void private function validate(array $data): void
@@ -230,4 +189,47 @@ class OrderCreateService
throw new OrderValidationFailedException($validation->errors()); throw new OrderValidationFailedException($validation->errors());
} }
} }
private function sendNotifications(array $orderData, array $tgInitData): void
{
$variables = [
'{store_name}' => $orderData['store_name'],
'{order_id}' => $orderData['order_id'],
'{customer}' => $orderData['firstname'] . ' ' . $orderData['lastname'],
'{email}' => $orderData['email'],
'{phone}' => $orderData['telephone'],
'{comment}' => $orderData['comment'],
'{address}' => $orderData['shipping_address_1'],
'{total}' => $orderData['total'],
'{ip}' => $orderData['ip'],
'{created_at}' => $orderData['date_added'],
];
$chatId = $this->settings->get('telegram.chat_id');
$template = $this->settings->get('telegram.owner_notification_template');
if ($chatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($chatId, $message);
} catch (Exception $exception) {
$this->logger->error("Telegram sendMessage to owner error. ChatID: $chatId, Message: $message");
$this->logger->logException($exception);
}
}
$allowsWriteToPm = Arr::get($tgInitData, 'user.allows_write_to_pm', false);
$customerChatId = Arr::get($tgInitData, 'user.id');
$template = $this->settings->get('telegram.customer_notification_template');
if ($allowsWriteToPm && $customerChatId && $template) {
$message = $this->telegramService->prepareMessage($template, $variables);
try {
$this->telegramService->sendMessage($customerChatId, $message);
} catch (Exception $exception) {
$this->logger->error("Telegram sendMessage to customer error. ChatID: $chatId, Message: $message");
$this->logger->logException($exception);
}
}
}
} }

View File

@@ -19,9 +19,6 @@ app
.use(VueTelegramPlugin); .use(VueTelegramPlugin);
const settings = useSettingsStore(); const settings = useSettingsStore();
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
categoriesStore.fetchCategories();
settings.load() settings.load()
.then(() => { .then(() => {
@@ -37,6 +34,11 @@ settings.load()
}); });
} }
}) })
.then(() => {
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
categoriesStore.fetchCategories();
})
.then(() => new AppMetaInitializer(settings).init()) .then(() => new AppMetaInitializer(settings).init())
.then(() => app.mount('#app')) .then(() => app.mount('#app'))
.then(() => window.Telegram.WebApp.ready()) .then(() => window.Telegram.WebApp.ready())

View File

@@ -16,7 +16,7 @@ export const useCartStore = defineStore('cart', {
getters: { getters: {
canCheckout: (state) => { canCheckout: (state) => {
if (state.isLoading) { if (state.isLoading || state.error_warning.length > 0) {
return false; return false;
} }
}, },

View File

@@ -15,6 +15,7 @@ export const useCheckoutStore = defineStore('checkout', {
tgData: null, tgData: null,
}, },
isLoading: false,
validationErrors: {}, validationErrors: {},
}), }),
@@ -27,19 +28,28 @@ export const useCheckoutStore = defineStore('checkout', {
actions: { actions: {
async makeOrder() { async makeOrder() {
try { try {
this.isLoading = true;
const data = window.Telegram.WebApp.initDataUnsafe; const data = window.Telegram.WebApp.initDataUnsafe;
if (! data.allows_write_to_pm) { console.log("Allows write to PM: ", data.user.allows_write_to_pm);
await window.Telegram.WebApp.requestWriteAccess((granted) => {
if (! data.user.allows_write_to_pm) {
console.log("Sending request");
const granted = await new Promise(resolve => {
window.Telegram.WebApp.requestWriteAccess((granted) => {
resolve(granted);
});
});
if (granted) { if (granted) {
data.user.allows_write_to_pm = true;
console.log('Пользователь разрешил отправку сообщений'); console.log('Пользователь разрешил отправку сообщений');
} else { } else {
alert('Вы не дали разрешение — бот не сможет отправлять вам уведомления'); alert('Вы не дали разрешение — бот не сможет отправлять вам уведомления');
} }
});
} }
this.customer.tgData = data.user; this.customer.tgData = data;
await storeOrder(this.customer); await storeOrder(this.customer);
await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success'); await window.Telegram.WebApp.HapticFeedback.notificationOccurred('success');
await useCartStore().getProducts(); await useCartStore().getProducts();
@@ -53,6 +63,8 @@ export const useCheckoutStore = defineStore('checkout', {
window.Telegram.WebApp.HapticFeedback.notificationOccurred('error'); window.Telegram.WebApp.HapticFeedback.notificationOccurred('error');
throw error; throw error;
} finally {
this.isLoading = false;
} }
}, },

View File

@@ -90,7 +90,9 @@
class="btn btn-primary" class="btn btn-primary"
:disabled="cart.canCheckout === false" :disabled="cart.canCheckout === false"
@click="goToCheckout" @click="goToCheckout"
>Перейти к оформлению</button> >
Перейти к оформлению
</button>
</div> </div>
</div> </div>

View File

@@ -55,7 +55,14 @@
<div <div
class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300"> class="fixed px-4 pb-10 pt-4 bottom-0 left-0 w-full bg-base-200 z-50 flex flex-col justify-between items-center gap-2 border-t-1 border-t-base-300">
<div v-if="error" class="text-error text-sm">{{ error }}</div> <div v-if="error" class="text-error text-sm">{{ error }}</div>
<button class="btn btn-primary w-full" @click="onCreateBtnClick">Создать заказ</button> <button
:disabled="checkout.isLoading"
class="btn btn-primary w-full"
@click="onCreateBtnClick"
>
<span v-if="checkout.isLoading" class="loading loading-spinner loading-sm"></span>
{{ btnText }}
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -65,13 +72,16 @@ import {useCheckoutStore} from "@/stores/CheckoutStore.js";
import TgInput from "@/components/Form/TgInput.vue"; import TgInput from "@/components/Form/TgInput.vue";
import TgTextarea from "@/components/Form/TgTextarea.vue"; import TgTextarea from "@/components/Form/TgTextarea.vue";
import {useRouter} from "vue-router"; import {useRouter} from "vue-router";
import {ref} from "vue"; import {computed, ref} from "vue";
const checkout = useCheckoutStore(); const checkout = useCheckoutStore();
const router = useRouter(); const router = useRouter();
const error = ref(null); const error = ref(null);
const btnText = computed(() => {
return checkout.isLoading ? 'Подождите...' : 'Создать заказ';
});
async function onCreateBtnClick() { async function onCreateBtnClick() {
try { try {
error.value = null; error.value = null;

View File

@@ -72,9 +72,10 @@
<button <button
class="btn btn-primary btn-lg w-full" class="btn btn-primary btn-lg w-full"
:class="isInCart ? 'btn-success' : 'btn-primary'" :class="isInCart ? 'btn-success' : 'btn-primary'"
:disabled="canAddToCart === false" :disabled="cart.isLoading || canAddToCart === false"
@click="actionBtnClick" @click="actionBtnClick"
> >
<span v-if="cart.isLoading" class="loading loading-spinner loading-sm"></span>
{{ btnText }} {{ btnText }}
</button> </button>
</div> </div>