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 ModelCustomerCustomerGroup $model_customer_customer_group
* @property ModelLocalisationOrderStatus $model_localisation_order_status
* @property DB $db
*/
class ControllerExtensionModuleTgshop extends Controller
{

View File

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

View File

@@ -55,7 +55,8 @@ class Application extends Container
$dotenv->load();
$errorHandler = new ErrorHandler(
$this->get(Logger::class)
$this->get(Logger::class),
$this,
);
$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;
use ErrorException;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\Exceptions\NonLoggableExceptionInterface;
use Openguru\OpenCartFramework\Http\JsonResponse;
use Openguru\OpenCartFramework\Http\Response;
@@ -15,10 +16,12 @@ use Throwable;
class ErrorHandler
{
private $logger;
private Application $app;
public function __construct(Logger $logger)
public function __construct(Logger $logger, Application $application)
{
$this->logger = $logger;
$this->app = $application;
}
public function register(): void
@@ -42,6 +45,15 @@ class ErrorHandler
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) {
$this->logger->logException($exception);
}

View File

@@ -13,6 +13,7 @@ class Request
private $files;
private $server;
private $content;
private array $headers = [];
public function __construct(
array $query,
@@ -20,26 +21,27 @@ class Request
array $cookies,
array $files,
array $server,
array $headers = [],
string $content = null
)
{
) {
$this->query = $query;
$this->request = $request;
$this->cookies = $cookies;
$this->files = $files;
$this->server = $server;
$this->headers = $headers;
$this->content = $content;
}
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
{
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;
@@ -49,7 +51,7 @@ class Request
{
$content = $this->getContent();
if (!$content) {
if (! $content) {
return $default;
}
@@ -84,7 +86,7 @@ class Request
public function header(string $name): ?string
{
$headers = [];
foreach (getallheaders() as $key => $value) {
foreach ($this->headers as $key => $value) {
$headers[mb_strtolower($key)] = trim($value);
}

View File

@@ -78,4 +78,13 @@ class SignatureValidator
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;
use GuzzleHttp\Client;
use JsonException;
use Openguru\OpenCartFramework\Application;
use Openguru\OpenCartFramework\Http\Request;
use Openguru\OpenCartFramework\Logger\Logger;
class TelegramService
@@ -18,7 +21,8 @@ class TelegramService
$this->client = $this->createGuzzleClient("https://api.telegram.org/bot{$botToken}/");
}
public function escapeTelegramMarkdownV2(string $text): string {
public function escapeTelegramMarkdownV2(string $text): string
{
$specials = ['_', '*', '[', ']', '(', ')', '~', '`', '>', '#', '+', '-', '=', '|', '{', '}', '.', '!'];
foreach ($specials as $char) {
$text = str_replace($char, '\\' . $char, $text);
@@ -33,10 +37,10 @@ class TelegramService
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) {
return false;
return;
}
$query = [
@@ -48,8 +52,6 @@ class TelegramService
$this->client->get('sendMessage', [
'query' => $query,
]);
return true;
}
private function createGuzzleClient(string $uri): Client
@@ -66,4 +68,29 @@ class TelegramService
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');
$chatId = $request->json('chat_id');
if (! $token) {
return new JsonResponse([
'message' => 'Не задан Telegram BotToken',
]);
}
if (! $chatId) {
return new JsonResponse([
'message' => 'Не задан ChatID.',
]);
}
$variables = [
'{store_name}' => $this->settings->get('oc_store_name'),
'{order_id}' => 777,
'{customer}' => 'Иван Вастльевич',
'{customer}' => 'Иван Васильевич',
'{email}' => 'telegram@opencart.com',
'{phone}' => '+79999999999',
'{comment}' => 'Это тестовый заказ',
@@ -108,11 +120,13 @@ class SettingsHandler
$this->telegramService
->setBotToken($token)
->sendMessage($chatId, $message);
return new JsonResponse([
'message' => 'Сообщение отправлено. Проверьте Telegram.',
]);
} catch (ClientException $exception) {
$json = json_decode($exception->getResponse()->getBody(), true);
return new JsonResponse([
'message' => $json['description'],
]);

View File

@@ -2,7 +2,9 @@
namespace App\ServiceProviders;
use App\Exceptions\CustomExceptionHandler;
use Openguru\OpenCartFramework\Container\ServiceProvider;
use Openguru\OpenCartFramework\Contracts\ExceptionHandlerInterface;
use Openguru\OpenCartFramework\Router\Router;
class AppServiceProvider extends ServiceProvider
@@ -10,5 +12,8 @@ class AppServiceProvider extends ServiceProvider
public function register(): void
{
$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;
$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();
} else {
$data['text_error'] = $this->oc->language->get('text_empty');
$data['totals'] = [];
$data['total'] = 0;
$data['total_text'] = '';
$data['products'] = [];
$data['total_products_count'] = 0;
unset($this->oc->session->data['success']);

View File

@@ -8,6 +8,7 @@ use Exception;
use Openguru\OpenCartFramework\Config\Settings;
use Openguru\OpenCartFramework\Logger\Logger;
use Openguru\OpenCartFramework\QueryBuilder\Connections\ConnectionInterface;
use Openguru\OpenCartFramework\Support\Arr;
use Openguru\OpenCartFramework\Telegram\TelegramService;
use Rakit\Validation\Validator;
use RuntimeException;
@@ -82,7 +83,7 @@ class OrderCreateService
$orderId = null;
$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);
if (! $success) {
@@ -163,52 +164,10 @@ class OrderCreateService
$this->cartService->flush();
$chatId = $this->settings->get('telegram.chat_id');
$template = $this->settings->get('telegram.owner_notification_template');
$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,
];
$orderData['order_id'] = $orderId;
$orderData['total'] = $cart['total_text'] ?? '';
if ($chatId && $template) {
$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);
}
}
$this->sendNotifications($orderData, $data['tgData']);
}
private function validate(array $data): void
@@ -230,4 +189,47 @@ class OrderCreateService
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);
const settings = useSettingsStore();
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
categoriesStore.fetchCategories();
settings.load()
.then(() => {
@@ -37,6 +34,11 @@ settings.load()
});
}
})
.then(() => {
const categoriesStore = useCategoriesStore();
categoriesStore.fetchTopCategories();
categoriesStore.fetchCategories();
})
.then(() => new AppMetaInitializer(settings).init())
.then(() => app.mount('#app'))
.then(() => window.Telegram.WebApp.ready())

View File

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

View File

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

View File

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

View File

@@ -55,7 +55,14 @@
<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">
<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>
</template>
@@ -65,13 +72,16 @@ import {useCheckoutStore} from "@/stores/CheckoutStore.js";
import TgInput from "@/components/Form/TgInput.vue";
import TgTextarea from "@/components/Form/TgTextarea.vue";
import {useRouter} from "vue-router";
import {ref} from "vue";
import {computed, ref} from "vue";
const checkout = useCheckoutStore();
const router = useRouter();
const error = ref(null);
const btnText = computed(() => {
return checkout.isLoading ? 'Подождите...' : 'Создать заказ';
});
async function onCreateBtnClick() {
try {
error.value = null;

View File

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