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:
NW
2026-08-08 23:46:56 +01:00
parent 3b9aa5d541
commit 338a700b3a
8 changed files with 655 additions and 3 deletions

View File

@@ -938,7 +938,7 @@ export function WalletsPage() {
<TabsContent value="seed-phrases" className="mt-6 space-y-4"> <TabsContent value="seed-phrases" className="mt-6 space-y-4">
{isSuperAdmin && ( {isSuperAdmin && (
<> <>
{/* Commission Warning */} {/* Commission Warning — информационное, НЕ блокирует супер-админа */}
{overview && overview.commissionDue > 0 && ( {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"> <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" /> <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)} Outstanding Commission: ${overview.commissionDue.toFixed(2)}
</p> </p>
<p className="text-xs text-orange-600 dark:text-orange-400 mt-1"> <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> </p>
</div> </div>
</div> </div>
@@ -962,7 +962,7 @@ export function WalletsPage() {
<Button <Button
variant="outline" variant="outline"
className="gap-2" className="gap-2"
disabled={overview && overview.commissionDue > 0} disabled={seedsLoading}
> >
<Lock className="h-4 w-4" /> <Lock className="h-4 w-4" />
{seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'} {seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}

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

View File

@@ -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', () => ({ vi.mock('../config/config.js', () => ({
__esModule: true, __esModule: true,
default: { default: {

View File

@@ -9,6 +9,7 @@ import UserService from "../../services/userService.js";
import PurchaseService from '../../services/purchaseService.js'; import PurchaseService from '../../services/purchaseService.js';
import Validators from '../../utils/validators.js'; import Validators from '../../utils/validators.js';
import { editOrSendCallback, deleteAndSend } from '../../utils/messageUtils.js'; import { editOrSendCallback, deleteAndSend } from '../../utils/messageUtils.js';
import { redirectIfPaused } from '../../utils/sleepGuard.js';
import { tForUser } from '../../i18n/index.js'; import { tForUser } from '../../i18n/index.js';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
@@ -99,6 +100,15 @@ export default class UserProductHandler {
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const country = decodeURIComponent(callbackQuery.data.replace('shop_country_', '')); 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 { try {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -141,6 +151,15 @@ export default class UserProductHandler {
const payload = callbackQuery.data.replace('shop_city_', ''); const payload = callbackQuery.data.replace('shop_city_', '');
const [country, city] = payload.split('|').map(decodeURIComponent); 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 { try {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -182,6 +201,15 @@ export default class UserProductHandler {
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const locationId = parseInt(callbackQuery.data.replace('shop_loc_', ''), 10); 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 { try {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -264,6 +292,15 @@ export default class UserProductHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const messageId = callbackQuery.message.message_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 { try {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -315,6 +352,15 @@ export default class UserProductHandler {
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const [locationId, categoryId] = callbackQuery.data.replace('shop_category_', '').split('_'); 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 { try {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -400,6 +446,15 @@ export default class UserProductHandler {
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const [locationId, categoryId, subcategoryId, photoMessageId] = callbackQuery.data.replace('shop_subcategory_', '').split('_'); 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 { try {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -470,6 +525,15 @@ export default class UserProductHandler {
const messageId = callbackQuery.message.message_id; const messageId = callbackQuery.message.message_id;
const productId = callbackQuery.data.replace('shop_product_', ''); 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 { try {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -588,6 +652,15 @@ export default class UserProductHandler {
const productId = callbackQuery.data.replace('increase_quantity_', ''); const productId = callbackQuery.data.replace('increase_quantity_', '');
const state = await userStates.get(chatId); 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 { try {
const product = await ProductService.getProductById(productId); const product = await ProductService.getProductById(productId);
@@ -648,6 +721,15 @@ export default class UserProductHandler {
const productId = callbackQuery.data.replace('decrease_quantity_', ''); const productId = callbackQuery.data.replace('decrease_quantity_', '');
const state = await userStates.get(chatId); 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 { try {
const product = await ProductService.getProductById(productId) const product = await ProductService.getProductById(productId)
@@ -707,6 +789,15 @@ export default class UserProductHandler {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const productId = callbackQuery.data.replace('buy_product_', ''); const productId = callbackQuery.data.replace('buy_product_', '');
const state = await userStates.get(chatId); 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 { try {
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);
@@ -833,6 +924,15 @@ export default class UserProductHandler {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_'); const [walletType, productId, quantity] = callbackQuery.data.replace('pay_with_', '').split('_');
const state = await userStates.get(chatId); 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 { try {
const user = await UserService.getUserByTelegramId(telegramId); const user = await UserService.getUserByTelegramId(telegramId);

View File

@@ -6,12 +6,23 @@ import UserService from '../../../services/userService.js';
import logger from '../../../utils/logger.js'; import logger from '../../../utils/logger.js';
import WalletHelpers from './helpers.js'; import WalletHelpers from './helpers.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js'; import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { redirectIfPaused } from '../../../utils/sleepGuard.js';
import { tForUser } from '../../../i18n/index.js'; import { tForUser } from '../../../i18n/index.js';
export default class CreateHandler { export default class CreateHandler {
static async handleAddWallet(callbackQuery) { static async handleAddWallet(callbackQuery) {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.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 user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en'; const lang = user?.language || 'en';
const t = tForUser(lang); const t = tForUser(lang);
@@ -41,6 +52,15 @@ export default class CreateHandler {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('generate_wallet_', '').replace('_', ' '); 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 user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en'; const lang = user?.language || 'en';
const t = tForUser(lang); const t = tForUser(lang);

View File

@@ -4,6 +4,7 @@ import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js'; import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js'; import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js'; import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { redirectIfPaused } from '../../../utils/sleepGuard.js';
import { tForUser } from '../../../i18n/index.js'; import { tForUser } from '../../../i18n/index.js';
const CRYPTO_SYMBOLS = { const CRYPTO_SYMBOLS = {
@@ -19,6 +20,15 @@ export default class DepositHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.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 user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en'; const lang = user?.language || 'en';
const t = tForUser(lang); const t = tForUser(lang);
@@ -71,6 +81,15 @@ export default class DepositHandler {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('deposit_wallet_', ''); 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 user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en'; const lang = user?.language || 'en';
const t = tForUser(lang); const t = tForUser(lang);
@@ -205,6 +224,15 @@ export default class DepositHandler {
const telegramId = callbackQuery.from.id; const telegramId = callbackQuery.from.id;
const walletType = callbackQuery.data.replace('deposit_crypto_', ''); 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 user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en'; const lang = user?.language || 'en';
const t = tForUser(lang); const t = tForUser(lang);

View File

@@ -4,6 +4,7 @@ import UserService from '../../../services/userService.js';
import bot from '../../../context/bot.js'; import bot from '../../../context/bot.js';
import logger from '../../../utils/logger.js'; import logger from '../../../utils/logger.js';
import { editOrSendCallback } from '../../../utils/messageUtils.js'; import { editOrSendCallback } from '../../../utils/messageUtils.js';
import { redirectIfPaused } from '../../../utils/sleepGuard.js';
import { tForUser } from '../../../i18n/index.js'; import { tForUser } from '../../../i18n/index.js';
export default class TopUpHandler { export default class TopUpHandler {
@@ -11,6 +12,15 @@ export default class TopUpHandler {
const chatId = callbackQuery.message.chat.id; const chatId = callbackQuery.message.chat.id;
const telegramId = callbackQuery.from.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 user = await UserService.getUserByTelegramId(telegramId);
const lang = user?.language || 'en'; const lang = user?.language || 'en';
const t = tForUser(lang); const t = tForUser(lang);

56
src/utils/sleepGuard.js Normal file
View 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);
}