fix(bot): issue #143 — sleep mode safety guards + super-admin seed access
- sleepGuard.js: assertShopOpen/redirectIfPaused — blocks write-flows on shop pause, redirects to AI dialog, fallback to sleep message - Blocked: buy/pay/quantity/top-up/deposit/wallet-create when paused - Soft redirect: catalog navigation (country/city/district/category/product) to AI on pause - Read-only flows untouched (purchase history, balance, tx history) - Tests: 10 sleepGuard tests (T1-T6 + alias + fallback), 42 total pass - fix(admin): super-admin seed phrases not blocked by commission (informational only) - wallet test mock updated for sleepGuard
This commit is contained in:
@@ -938,7 +938,7 @@ export function WalletsPage() {
|
||||
<TabsContent value="seed-phrases" className="mt-6 space-y-4">
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
{/* Commission Warning */}
|
||||
{/* Commission Warning — информационное, НЕ блокирует супер-админа */}
|
||||
{overview && overview.commissionDue > 0 && (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4 dark:bg-orange-950 dark:border-orange-800">
|
||||
<AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
|
||||
@@ -947,7 +947,7 @@ export function WalletsPage() {
|
||||
Outstanding Commission: ${overview.commissionDue.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400 mt-1">
|
||||
Please pay the outstanding commission before accessing seed phrases.
|
||||
Reminder: the shop commission is due. Super admins always have full access to seed phrases.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -962,7 +962,7 @@ export function WalletsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
disabled={overview && overview.commissionDue > 0}
|
||||
disabled={seedsLoading}
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
{seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}
|
||||
|
||||
432
src/__tests__/sleepGuard.test.js
Normal file
432
src/__tests__/sleepGuard.test.js
Normal file
@@ -0,0 +1,432 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// ── Hoisted mocks (доступны в фабриках vi.mock) ──
|
||||
const { botMock, chatbotServiceMock, userServiceMock, productServiceMock, locationServiceMock, categoryServiceMock, purchaseServiceMock, userStatesMock } = vi.hoisted(() => ({
|
||||
botMock: {
|
||||
deleteMessage: vi.fn().mockResolvedValue({}),
|
||||
sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }),
|
||||
sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }),
|
||||
editMessageText: vi.fn().mockResolvedValue({ message_id: 222 }),
|
||||
editMessageReplyMarkup: vi.fn().mockResolvedValue({}),
|
||||
answerCallbackQuery: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
chatbotServiceMock: {
|
||||
isSleepMode: vi.fn(),
|
||||
isChatbotEnabled: vi.fn(),
|
||||
getSleepMessage: vi.fn(),
|
||||
getWelcomeMessage: vi.fn(),
|
||||
sendToChatbot: vi.fn(),
|
||||
},
|
||||
userServiceMock: {
|
||||
getUserByTelegramId: vi.fn().mockResolvedValue({ id: 1, language: 'en' }),
|
||||
getUserBalance: vi.fn().mockResolvedValue(100),
|
||||
recalculateUserBalanceByTelegramId: vi.fn().mockResolvedValue(undefined),
|
||||
setUserLanguage: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
productServiceMock: {
|
||||
getDetailedProductById: vi.fn(),
|
||||
getProductById: vi.fn(),
|
||||
getProductAvailability: vi.fn(),
|
||||
getProductsByCategoryId: vi.fn().mockResolvedValue([]),
|
||||
getProductsByLocationAndCategory: vi.fn().mockResolvedValue([]),
|
||||
decreaseProductQuantity: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
locationServiceMock: {
|
||||
getCountries: vi.fn().mockResolvedValue([]),
|
||||
getCitiesByCountry: vi.fn().mockResolvedValue([]),
|
||||
getLocationsByCountryAndCity: vi.fn().mockResolvedValue([]),
|
||||
getActiveLocationById: vi.fn().mockResolvedValue(null),
|
||||
getLocationById: vi.fn().mockResolvedValue({ country: 'US', city: 'NY', district: 'Manhattan' }),
|
||||
},
|
||||
categoryServiceMock: {
|
||||
getCategoriesByLocationId: vi.fn().mockResolvedValue([]),
|
||||
getCategoriesWithProductsByLocationId: vi.fn().mockResolvedValue([]),
|
||||
getCategoryById: vi.fn().mockResolvedValue({ name: 'Test' }),
|
||||
getSubcategoryById: vi.fn().mockResolvedValue({ name: 'Test' }),
|
||||
},
|
||||
purchaseServiceMock: {
|
||||
createPurchase: vi.fn().mockResolvedValue(1),
|
||||
},
|
||||
userStatesMock: {
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Top-level mocks (обязательно на верхнем уровне для vitest) ──
|
||||
vi.mock('../config/config.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
BOT_TOKEN: 'test-token',
|
||||
ADMIN_IDS: ['123456789'],
|
||||
SUPER_ADMIN_IDS: ['123456789'],
|
||||
SUPPORT_LINK: 'https://t.me/support',
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
ENCRYPTION_KEY: 'x'.repeat(64),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/logger.js', () => ({
|
||||
__esModule: true,
|
||||
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), fatal: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('../config/database.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
allAsync: vi.fn().mockResolvedValue([]),
|
||||
getAsync: vi.fn().mockResolvedValue({}),
|
||||
runAsync: vi.fn().mockResolvedValue({ changes: 1, lastInsertRowid: 1 }),
|
||||
},
|
||||
}));
|
||||
|
||||
// Чистый ASCII-фикстур — никаких битых emoji (иначе Vite-парсер падает)
|
||||
const en = {
|
||||
products: {
|
||||
location_disabled: 'This location is no longer available.',
|
||||
back_to_main: 'Main Menu',
|
||||
error_loading_categories: 'Error loading categories.',
|
||||
error_loading_product: 'Error loading product details.',
|
||||
buy_now: 'Buy Now',
|
||||
back: 'Back',
|
||||
product_price: 'Price',
|
||||
product_description: 'Description',
|
||||
product_available: 'Available',
|
||||
product_category: 'Category',
|
||||
infinite_stock: 'Always available',
|
||||
no_description: 'No description provided',
|
||||
no_photo: 'No photo available',
|
||||
select_country: 'Select country',
|
||||
select_city: 'Select city in {{country}}',
|
||||
select_district: 'Select district in {{city}}',
|
||||
district_unknown: 'Unknown',
|
||||
select_category: 'Select category',
|
||||
select_product: 'Select product',
|
||||
no_products: 'No products available',
|
||||
no_categories: 'No categories available',
|
||||
no_products_category: 'No products in this category',
|
||||
no_products_subcategory: 'No products in this subcategory',
|
||||
products_in: 'Products in {{name}}',
|
||||
back_to_countries: 'Back to countries',
|
||||
back_to_cities: 'Back to cities',
|
||||
back_to_subcategories: 'Back to subcategories',
|
||||
error_loading: 'Error loading.',
|
||||
error_loading_cities: 'Error loading cities.',
|
||||
error_loading_districts: 'Error loading districts.',
|
||||
},
|
||||
purchase: {
|
||||
insufficient_balance: 'Insufficient balance: {{balance}} < {{total}}',
|
||||
top_up_balance: 'Top Up',
|
||||
need_wallet: 'You need a wallet first',
|
||||
add_wallet: 'Add Wallet',
|
||||
pay: 'Pay',
|
||||
cancel: 'Cancel',
|
||||
summary: 'Purchase Summary',
|
||||
product: 'Product',
|
||||
quantity: 'Quantity',
|
||||
total: 'Total',
|
||||
error_processing: 'Error processing purchase.',
|
||||
invalid_wallet: 'Invalid wallet type.',
|
||||
invalid_product: 'Invalid product.',
|
||||
invalid_quantity: 'Invalid quantity.',
|
||||
not_enough_money: 'Not enough money.',
|
||||
not_enough_stock: 'Not enough stock: {{count}}',
|
||||
details: 'Purchase Details',
|
||||
location: 'Location',
|
||||
category: 'Category',
|
||||
private_info: 'Private Info',
|
||||
hidden_location: 'Hidden Location',
|
||||
coordinates: 'Coordinates',
|
||||
view_purchase: 'View Purchase',
|
||||
},
|
||||
wallet: {
|
||||
profile_not_found: 'Profile not found.',
|
||||
no_wallets: 'No wallets found.',
|
||||
back: 'Back',
|
||||
back_to_balance: 'Back to Balance',
|
||||
your_wallets: 'Your Wallets',
|
||||
balance: 'Balance',
|
||||
value: 'Value',
|
||||
address: 'Address',
|
||||
deposit_via_crypto: 'Deposit via Crypto',
|
||||
error_loading: 'Error loading wallets.',
|
||||
select_crypto: 'Select cryptocurrency',
|
||||
invalid_wallet_type: 'Invalid wallet type.',
|
||||
user_not_found: 'User not found.',
|
||||
wallet_generated: 'Wallet generated!',
|
||||
wallet_type: 'Type',
|
||||
network: 'Network',
|
||||
previous_archived: 'Previous wallet archived.',
|
||||
recovery_stored: 'Recovery stored.',
|
||||
error_generating: 'Error generating wallet.',
|
||||
deposit_select_gateway: 'Select deposit gateway',
|
||||
no_wallets_prefix: 'No wallets.',
|
||||
wallet_not_found: 'Wallet not found.',
|
||||
wallet_not_found_short: 'Wallet not found',
|
||||
profile_not_found_short: 'Profile not found',
|
||||
deposit_title: 'Deposit {{type}}',
|
||||
deposit_instructions_title: 'Instructions',
|
||||
deposit_step1: 'Step 1',
|
||||
deposit_step2: 'Step 2 {{type}}',
|
||||
deposit_step3: 'Step 3',
|
||||
deposit_step4: 'Step 4 {{type}}',
|
||||
deposit_step5: 'Step 5',
|
||||
deposit_step6: 'Step 6',
|
||||
deposit_your_address: 'Your {{type}} address',
|
||||
deposit_important_title: 'Important',
|
||||
deposit_important1: 'Important 1',
|
||||
deposit_important2: 'Important 2',
|
||||
deposit_important3: 'Important 3',
|
||||
deposit_important4: 'Important 4',
|
||||
deposit_important5: 'Important 5',
|
||||
deposit_open_mercuryo: 'Open Mercuryo',
|
||||
deposit_pay_crypto: 'Pay with Crypto',
|
||||
deposit_copy_address: 'Copy Address',
|
||||
deposit_choose_different: 'Choose Different',
|
||||
deposit_wallet_address: '{{type}} Wallet Address',
|
||||
back_to_deposit: 'Back to Deposit',
|
||||
deposit_address_sent: 'Address sent: {{type}}',
|
||||
error_copying_address: 'Error copying address.',
|
||||
error_deposit_instructions: 'Error loading deposit instructions.',
|
||||
deposit_crypto_title: 'Crypto Deposit {{type}}',
|
||||
deposit_crypto_scan_qr: 'Scan QR code',
|
||||
deposit_crypto_address: '{{type}} Address',
|
||||
deposit_crypto_instruction: 'Send {{type}} to this address',
|
||||
},
|
||||
bot: {
|
||||
contact_support: 'Contact Support',
|
||||
},
|
||||
};
|
||||
|
||||
function getNestedValue(obj, keyPath) {
|
||||
return keyPath.split('.').reduce((o, k) => o?.[k], obj);
|
||||
}
|
||||
|
||||
vi.mock('../i18n/index.js', () => ({
|
||||
__esModule: true,
|
||||
tForUser: () => (key) => getNestedValue(en, key) || key,
|
||||
LANGUAGE_NAMES: { en: 'English', es: 'Espanol', de: 'Deutsch' },
|
||||
AVAILABLE_LANGUAGES: ['en', 'es', 'de'],
|
||||
}));
|
||||
|
||||
vi.mock('../context/bot.js', () => ({
|
||||
__esModule: true,
|
||||
default: botMock,
|
||||
botAvailable: true,
|
||||
}));
|
||||
|
||||
vi.mock('../context/userStates.js', () => ({
|
||||
__esModule: true,
|
||||
default: userStatesMock,
|
||||
}));
|
||||
|
||||
vi.mock('../services/userService.js', () => ({
|
||||
__esModule: true,
|
||||
default: userServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../services/productService.js', () => ({
|
||||
__esModule: true,
|
||||
default: productServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../services/locationService.js', () => ({
|
||||
__esModule: true,
|
||||
default: locationServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../services/categoryService.js', () => ({
|
||||
__esModule: true,
|
||||
default: categoryServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../services/purchaseService.js', () => ({
|
||||
__esModule: true,
|
||||
default: purchaseServiceMock,
|
||||
}));
|
||||
|
||||
vi.mock('../utils/validators.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
isValidWalletType: vi.fn().mockReturnValue(true),
|
||||
isValidNumericId: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../services/chatbotService.js', () => ({
|
||||
__esModule: true,
|
||||
default: chatbotServiceMock,
|
||||
}));
|
||||
|
||||
import { assertShopOpen, redirectIfPaused } from '../utils/sleepGuard.js';
|
||||
import UserProductHandler from '../handlers/userHandlers/userProductHandler.js';
|
||||
|
||||
describe('sleepGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
chatbotServiceMock.isSleepMode.mockResolvedValue(false);
|
||||
chatbotServiceMock.sendToChatbot.mockResolvedValue({ reply: 'AI: Hello! How can I help?' });
|
||||
chatbotServiceMock.getSleepMessage.mockResolvedValue('Shop is paused.');
|
||||
userServiceMock.getUserByTelegramId.mockResolvedValue({ id: 1, language: 'en' });
|
||||
});
|
||||
|
||||
// T1: shop open — no block
|
||||
it('T1: returns false when isSleepMode is false, no bot calls', async () => {
|
||||
const result = await assertShopOpen({
|
||||
chatId: 123,
|
||||
telegramId: 456,
|
||||
callbackQueryId: 'cq-1',
|
||||
username: 'testuser',
|
||||
name: 'Test',
|
||||
action: 'buy',
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
expect(botMock.sendMessage).not.toHaveBeenCalled();
|
||||
expect(botMock.answerCallbackQuery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// T2: sleep mode — blocks and sends message
|
||||
it('T2: returns true, answers callback, sends AI reply', async () => {
|
||||
chatbotServiceMock.isSleepMode.mockResolvedValue(true);
|
||||
const result = await assertShopOpen({
|
||||
chatId: 123,
|
||||
telegramId: 456,
|
||||
callbackQueryId: 'cq-1',
|
||||
username: 'testuser',
|
||||
name: 'Test',
|
||||
action: 'buy',
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(botMock.answerCallbackQuery).toHaveBeenCalledWith('cq-1');
|
||||
expect(botMock.sendMessage).toHaveBeenCalledWith(123, 'AI: Hello! How can I help?', {
|
||||
reply_markup: { remove_keyboard: true },
|
||||
});
|
||||
});
|
||||
|
||||
// T3: sends AI welcome with user language
|
||||
it('T3: sends AI welcome with correct language from user', async () => {
|
||||
chatbotServiceMock.isSleepMode.mockResolvedValue(true);
|
||||
userServiceMock.getUserByTelegramId.mockResolvedValue({ id: 1, language: 'de' });
|
||||
await assertShopOpen({
|
||||
chatId: 123,
|
||||
telegramId: 456,
|
||||
callbackQueryId: 'cq-1',
|
||||
username: 'testuser',
|
||||
name: 'Test',
|
||||
action: 'buy',
|
||||
});
|
||||
expect(chatbotServiceMock.sendToChatbot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: '456',
|
||||
message: '/start',
|
||||
telegramId: '456',
|
||||
language: 'de',
|
||||
username: 'testuser',
|
||||
name: 'Test',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// T4: handleBuyProduct blocked in sleep mode
|
||||
it('T4: handleBuyProduct returns early in sleep mode, no ProductService call', async () => {
|
||||
chatbotServiceMock.isSleepMode.mockResolvedValue(true);
|
||||
const callbackQuery = {
|
||||
id: 'cq-buy',
|
||||
from: { id: 12345, first_name: 'Test', username: 'testuser' },
|
||||
message: { chat: { id: 67890 }, message_id: 111, reply_markup: { inline_keyboard: [] } },
|
||||
data: 'buy_product_42',
|
||||
};
|
||||
await UserProductHandler.handleBuyProduct(callbackQuery);
|
||||
expect(productServiceMock.getProductById).not.toHaveBeenCalled();
|
||||
expect(botMock.sendMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// T5: read-only flows untouched (source-level)
|
||||
it('T5: userPurchaseHandler does not import sleepGuard', async () => {
|
||||
const fs = await import('fs');
|
||||
const content = fs.readFileSync(
|
||||
new URL('../handlers/userHandlers/userPurchaseHandler.js', import.meta.url).pathname,
|
||||
'utf-8'
|
||||
);
|
||||
expect(content).not.toContain('sleepGuard');
|
||||
});
|
||||
|
||||
it('T5: balanceHandler does not import sleepGuard', async () => {
|
||||
const fs = await import('fs');
|
||||
const content = fs.readFileSync(
|
||||
new URL('../handlers/userHandlers/wallet/balanceHandler.js', import.meta.url).pathname,
|
||||
'utf-8'
|
||||
);
|
||||
expect(content).not.toContain('sleepGuard');
|
||||
});
|
||||
|
||||
it('T5: historyHandler does not import sleepGuard', async () => {
|
||||
const fs = await import('fs');
|
||||
const content = fs.readFileSync(
|
||||
new URL('../handlers/userHandlers/wallet/historyHandler.js', import.meta.url).pathname,
|
||||
'utf-8'
|
||||
);
|
||||
expect(content).not.toContain('sleepGuard');
|
||||
});
|
||||
|
||||
// T6: regression — buy works when shop is open
|
||||
it('T6: handleBuyProduct calls getProductById when shop is open', async () => {
|
||||
chatbotServiceMock.isSleepMode.mockResolvedValue(false);
|
||||
productServiceMock.getProductById.mockResolvedValue({
|
||||
id: 42,
|
||||
name: 'Test Product',
|
||||
price: 10,
|
||||
is_mono: true,
|
||||
location_id: 1,
|
||||
category_id: 1,
|
||||
photo_url: null,
|
||||
hidden_photo_url: null,
|
||||
private_data: 'secret',
|
||||
hidden_description: 'hidden',
|
||||
hidden_coordinates: '0,0',
|
||||
});
|
||||
productServiceMock.getProductAvailability.mockResolvedValue({ loc_active: true, cat_active: true });
|
||||
const callbackQuery = {
|
||||
id: 'cq-buy-open',
|
||||
from: { id: 12345, first_name: 'Test', username: 'testuser' },
|
||||
message: { chat: { id: 67890 }, message_id: 111, reply_markup: { inline_keyboard: [] } },
|
||||
data: 'buy_product_42',
|
||||
};
|
||||
await UserProductHandler.handleBuyProduct(callbackQuery);
|
||||
expect(productServiceMock.getProductById).toHaveBeenCalledWith('42');
|
||||
});
|
||||
|
||||
// redirectIfPaused alias
|
||||
it('redirectIfPaused delegates to assertShopOpen', async () => {
|
||||
chatbotServiceMock.isSleepMode.mockResolvedValue(true);
|
||||
const result = await redirectIfPaused({
|
||||
chatId: 123,
|
||||
telegramId: 456,
|
||||
callbackQueryId: 'cq-1',
|
||||
username: 'testuser',
|
||||
name: 'Test',
|
||||
action: 'navigate',
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(botMock.sendMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Fallback: sendToChatbot fails → still blocks with sleep message
|
||||
it('fallback: still blocks and shows sleep message when AI is unreachable', async () => {
|
||||
chatbotServiceMock.isSleepMode.mockResolvedValue(true);
|
||||
chatbotServiceMock.sendToChatbot.mockRejectedValue(new Error('Network error'));
|
||||
const result = await assertShopOpen({
|
||||
chatId: 123,
|
||||
telegramId: 456,
|
||||
callbackQueryId: 'cq-1',
|
||||
username: 'testuser',
|
||||
name: 'Test',
|
||||
action: 'buy',
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
expect(botMock.sendMessage).toHaveBeenCalledWith(123, 'Shop is paused.', {
|
||||
reply_markup: { remove_keyboard: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,12 @@ vi.mock('../utils/logger.js', () => ({
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../utils/sleepGuard.js', () => ({
|
||||
__esModule: true,
|
||||
redirectIfPaused: vi.fn().mockResolvedValue(false), // магазин открыт — не блокируем
|
||||
assertShopOpen: vi.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('../config/config.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import UserService from "../../services/userService.js";
|
||||
import PurchaseService from '../../services/purchaseService.js';
|
||||
import Validators from '../../utils/validators.js';
|
||||
import { editOrSendCallback, deleteAndSend } from '../../utils/messageUtils.js';
|
||||
import { redirectIfPaused } from '../../utils/sleepGuard.js';
|
||||
import { tForUser } from '../../i18n/index.js';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -99,6 +100,15 @@ export default class UserProductHandler {
|
||||
const messageId = callbackQuery.message.message_id;
|
||||
const country = decodeURIComponent(callbackQuery.data.replace('shop_country_', ''));
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'country_selection',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
@@ -141,6 +151,15 @@ export default class UserProductHandler {
|
||||
const payload = callbackQuery.data.replace('shop_city_', '');
|
||||
const [country, city] = payload.split('|').map(decodeURIComponent);
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'city_selection',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
@@ -182,6 +201,15 @@ export default class UserProductHandler {
|
||||
const messageId = callbackQuery.message.message_id;
|
||||
const locationId = parseInt(callbackQuery.data.replace('shop_loc_', ''), 10);
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'district_selection',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
@@ -264,6 +292,15 @@ export default class UserProductHandler {
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
const messageId = callbackQuery.message.message_id;
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'district_back',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
@@ -315,6 +352,15 @@ export default class UserProductHandler {
|
||||
const messageId = callbackQuery.message.message_id;
|
||||
const [locationId, categoryId] = callbackQuery.data.replace('shop_category_', '').split('_');
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'category_selection',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
@@ -400,6 +446,15 @@ export default class UserProductHandler {
|
||||
const messageId = callbackQuery.message.message_id;
|
||||
const [locationId, categoryId, subcategoryId, photoMessageId] = callbackQuery.data.replace('shop_subcategory_', '').split('_');
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'subcategory_selection',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
@@ -470,6 +525,15 @@ export default class UserProductHandler {
|
||||
const messageId = callbackQuery.message.message_id;
|
||||
const productId = callbackQuery.data.replace('shop_product_', '');
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'product_selection',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
@@ -588,6 +652,15 @@ export default class UserProductHandler {
|
||||
const productId = callbackQuery.data.replace('increase_quantity_', '');
|
||||
const state = await userStates.get(chatId);
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'increase_quantity',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const product = await ProductService.getProductById(productId);
|
||||
|
||||
@@ -648,6 +721,15 @@ export default class UserProductHandler {
|
||||
const productId = callbackQuery.data.replace('decrease_quantity_', '');
|
||||
const state = await userStates.get(chatId);
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId: callbackQuery.from.id,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'decrease_quantity',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const product = await ProductService.getProductById(productId)
|
||||
|
||||
@@ -708,6 +790,15 @@ export default class UserProductHandler {
|
||||
const productId = callbackQuery.data.replace('buy_product_', '');
|
||||
const state = await userStates.get(chatId);
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'buy',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
if (!user) {
|
||||
@@ -834,6 +925,15 @@ export default class UserProductHandler {
|
||||
const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_');
|
||||
const state = await userStates.get(chatId);
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'pay',
|
||||
})) return;
|
||||
|
||||
try {
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
const lang = user?.language || 'en';
|
||||
|
||||
@@ -6,12 +6,23 @@ import UserService from '../../../services/userService.js';
|
||||
import logger from '../../../utils/logger.js';
|
||||
import WalletHelpers from './helpers.js';
|
||||
import { editOrSendCallback } from '../../../utils/messageUtils.js';
|
||||
import { redirectIfPaused } from '../../../utils/sleepGuard.js';
|
||||
import { tForUser } from '../../../i18n/index.js';
|
||||
|
||||
export default class CreateHandler {
|
||||
static async handleAddWallet(callbackQuery) {
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
const telegramId = callbackQuery.from.id;
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'add_wallet',
|
||||
})) return;
|
||||
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
@@ -41,6 +52,15 @@ export default class CreateHandler {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const walletType = callbackQuery.data.replace('generate_wallet_', '').replace('_', ' ');
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'generate_wallet',
|
||||
})) return;
|
||||
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
|
||||
@@ -4,6 +4,7 @@ import UserService from '../../../services/userService.js';
|
||||
import bot from '../../../context/bot.js';
|
||||
import logger from '../../../utils/logger.js';
|
||||
import { editOrSendCallback } from '../../../utils/messageUtils.js';
|
||||
import { redirectIfPaused } from '../../../utils/sleepGuard.js';
|
||||
import { tForUser } from '../../../i18n/index.js';
|
||||
|
||||
const CRYPTO_SYMBOLS = {
|
||||
@@ -19,6 +20,15 @@ export default class DepositHandler {
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
const telegramId = callbackQuery.from.id;
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'deposit_select',
|
||||
})) return;
|
||||
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
@@ -71,6 +81,15 @@ export default class DepositHandler {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const walletType = callbackQuery.data.replace('deposit_wallet_', '');
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'deposit_instruction',
|
||||
})) return;
|
||||
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
@@ -205,6 +224,15 @@ export default class DepositHandler {
|
||||
const telegramId = callbackQuery.from.id;
|
||||
const walletType = callbackQuery.data.replace('deposit_crypto_', '');
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'deposit_crypto_qr',
|
||||
})) return;
|
||||
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
|
||||
@@ -4,6 +4,7 @@ import UserService from '../../../services/userService.js';
|
||||
import bot from '../../../context/bot.js';
|
||||
import logger from '../../../utils/logger.js';
|
||||
import { editOrSendCallback } from '../../../utils/messageUtils.js';
|
||||
import { redirectIfPaused } from '../../../utils/sleepGuard.js';
|
||||
import { tForUser } from '../../../i18n/index.js';
|
||||
|
||||
export default class TopUpHandler {
|
||||
@@ -11,6 +12,15 @@ export default class TopUpHandler {
|
||||
const chatId = callbackQuery.message.chat.id;
|
||||
const telegramId = callbackQuery.from.id;
|
||||
|
||||
if (await redirectIfPaused({
|
||||
chatId,
|
||||
telegramId,
|
||||
callbackQueryId: callbackQuery.id,
|
||||
username: callbackQuery.from?.username,
|
||||
name: callbackQuery.from?.first_name,
|
||||
action: 'top_up',
|
||||
})) return;
|
||||
|
||||
const user = await UserService.getUserByTelegramId(telegramId);
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
|
||||
56
src/utils/sleepGuard.js
Normal file
56
src/utils/sleepGuard.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import bot from '../context/bot.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import chatbotService from '../services/chatbotService.js';
|
||||
import UserService from '../services/userService.js';
|
||||
import { tForUser } from '../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Блокирует запись-флоу при паузе магазина.
|
||||
* Возвращает true, если магазин на паузе и действие заблокировано (handler должен return).
|
||||
*/
|
||||
export async function assertShopOpen({ chatId, telegramId, callbackQueryId, username, name, action }) {
|
||||
if (!(await chatbotService.isSleepMode())) return false;
|
||||
const sleepMsg = await chatbotService.getSleepMessage();
|
||||
try {
|
||||
const user = telegramId ? await UserService.getUserByTelegramId(telegramId) : null;
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
if (callbackQueryId) {
|
||||
await bot.answerCallbackQuery(callbackQueryId).catch(() => {});
|
||||
}
|
||||
let welcomeReply = sleepMsg; // Default to sleep message
|
||||
try {
|
||||
const welcome = await chatbotService.sendToChatbot({
|
||||
sessionId: telegramId != null ? String(telegramId) : undefined,
|
||||
message: '/start',
|
||||
telegramId: telegramId != null ? String(telegramId) : undefined,
|
||||
language: lang,
|
||||
username,
|
||||
name,
|
||||
});
|
||||
welcomeReply = welcome.reply || sleepMsg;
|
||||
} catch (welcomeErr) {
|
||||
// Fallback to sleep message if AI is unreachable
|
||||
logger.warn({ welcomeErr }, 'Failed to get AI welcome message, falling back to sleep message');
|
||||
}
|
||||
await bot.sendMessage(chatId, welcomeReply, {
|
||||
reply_markup: { remove_keyboard: true },
|
||||
});
|
||||
logger.info({ action, telegramId }, 'Sleep mode blocked action');
|
||||
} catch (err) {
|
||||
logger.error({ err, action }, 'sleepGuard error');
|
||||
// In case of any other error, still send sleep message
|
||||
await bot.sendMessage(chatId, sleepMsg, {
|
||||
reply_markup: { remove_keyboard: true },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Мягкое перенаправление для навигации каталога: при паузе показывает ИИ-диалог.
|
||||
* Алиас для assertShopOpen — та же логика.
|
||||
*/
|
||||
export async function redirectIfPaused(params) {
|
||||
return assertShopOpen(params);
|
||||
}
|
||||
Reference in New Issue
Block a user