fix(bot): issue #127 — active-state sync, disabled-entity handling, double-tap lock, state reset
- BUG-01: getActiveLocationById + product availability checks (loc_active/cat_active) — disabled locations/categories filtered from bot menus - BUG-02: graceful redirect to main menu (location_disabled notice) when entity disabled mid-flow, no crash in handleDistrictSelection/handleProductSelection/handleBuyProduct/handlePay - BUG-03: per-user callback lock (LOCK_MS 1500ms debounce) in utils/callbackLock.js — duplicate taps dropped - BUG-04: resetUserContext clears tracked photo/product messages + userStates on main-menu navigation and /start - fix: handlePay uses validated numeric quantity (was raw string) for price/stock/purchase writes - tests: 7 new (botBugFixes.test.js), updated userProductHandler tests — 30 total pass - bump v1.2.2
This commit is contained in:
239
src/__tests__/botBugFixes.test.js
Normal file
239
src/__tests__/botBugFixes.test.js
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from '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()
|
||||
}
|
||||
}));
|
||||
|
||||
const recorded = {
|
||||
calls: []
|
||||
};
|
||||
|
||||
vi.mock('../config/database.js', () => {
|
||||
const db = {
|
||||
allAsync: vi.fn(async (sql, params = []) => {
|
||||
recorded.calls.push({ method: 'allAsync', sql, params });
|
||||
return [];
|
||||
}),
|
||||
getAsync: vi.fn(async (sql, params = []) => {
|
||||
recorded.calls.push({ method: 'getAsync', sql, params });
|
||||
return {};
|
||||
}),
|
||||
runAsync: vi.fn(async (sql, params = []) => {
|
||||
recorded.calls.push({ method: 'runAsync', sql, params });
|
||||
return {};
|
||||
})
|
||||
};
|
||||
return { __esModule: true, default: db };
|
||||
});
|
||||
|
||||
const en = {
|
||||
products: {
|
||||
location_disabled: 'This location is no longer available.',
|
||||
back_to_main: '🏠 Main Menu',
|
||||
error_loading_categories: 'Error loading categories. Please try again.'
|
||||
}
|
||||
};
|
||||
|
||||
function getNestedValue(obj, keyPath) {
|
||||
return keyPath.split('.').reduce((o, k) => o?.[k], obj);
|
||||
}
|
||||
|
||||
vi.mock('../i18n/index.js', () => ({
|
||||
__esModule: true,
|
||||
tForUser: (lang) => (key) => getNestedValue(en, key) || key
|
||||
}));
|
||||
|
||||
vi.mock('../context/bot.js', () => {
|
||||
const bot = {
|
||||
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 }),
|
||||
answerCallbackQuery: vi.fn().mockResolvedValue({})
|
||||
};
|
||||
return { __esModule: true, default: bot, botAvailable: true };
|
||||
});
|
||||
|
||||
vi.mock('../context/userStates.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../services/userService.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getUserByTelegramId: vi.fn().mockResolvedValue({ language: 'en' })
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../services/categoryService.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getCategoriesByLocationId: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}));
|
||||
|
||||
import LocationService from '../services/locationService.js';
|
||||
import UserProductHandler from '../handlers/userHandlers/userProductHandler.js';
|
||||
import bot from '../context/bot.js';
|
||||
import userStates from '../context/userStates.js';
|
||||
import { resetUserContext } from '../utils/messageUtils.js';
|
||||
import * as callbackLock from '../utils/callbackLock.js';
|
||||
import CategoryService from '../services/categoryService.js';
|
||||
|
||||
describe('BUG-01: getActiveLocationById SQL filter', () => {
|
||||
beforeEach(() => {
|
||||
recorded.calls = [];
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('getActiveLocationById includes is_active = 1 and id = 5', async () => {
|
||||
await LocationService.getActiveLocationById(5);
|
||||
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||
expect(sql).toContain('is_active = 1');
|
||||
expect(sql).toContain('id = ?');
|
||||
const params = recorded.calls.find(c => c.method === 'getAsync')?.params;
|
||||
expect(params).toEqual([5]);
|
||||
});
|
||||
|
||||
it('getLocationById does NOT include is_active filter', async () => {
|
||||
await LocationService.getLocationById(5);
|
||||
const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql;
|
||||
expect(sql).not.toContain('is_active');
|
||||
expect(sql).toContain('id = ?');
|
||||
const params = recorded.calls.find(c => c.method === 'getAsync')?.params;
|
||||
expect(params).toEqual([5]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BUG-02: handleDistrictSelection disabled location path', () => {
|
||||
let getActiveLocationByIdSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getActiveLocationByIdSpy = vi.spyOn(LocationService, 'getActiveLocationById').mockResolvedValue(null);
|
||||
CategoryService.getCategoriesByLocationId.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getActiveLocationByIdSpy?.mockRestore();
|
||||
});
|
||||
|
||||
it('shows location_disabled notice and resets state when district is disabled', async () => {
|
||||
const callbackQuery = {
|
||||
id: 'cq-disabled',
|
||||
from: { id: 12345, first_name: 'Test' },
|
||||
message: {
|
||||
chat: { id: 67890 },
|
||||
message_id: 111
|
||||
},
|
||||
data: 'shop_loc_42'
|
||||
};
|
||||
|
||||
await expect(UserProductHandler.handleDistrictSelection(callbackQuery)).resolves.toBeUndefined();
|
||||
|
||||
expect(bot.answerCallbackQuery).toHaveBeenCalledWith('cq-disabled');
|
||||
expect(userStates.delete).toHaveBeenCalledWith(67890);
|
||||
expect(bot.editMessageText).toHaveBeenCalledTimes(1);
|
||||
const [text, options] = bot.editMessageText.mock.calls[0];
|
||||
expect(text).toContain(en.products.location_disabled);
|
||||
expect(options.chat_id).toBe(67890);
|
||||
expect(options.message_id).toBe(111);
|
||||
const keyboard = options.reply_markup.inline_keyboard;
|
||||
expect(keyboard).toHaveLength(1);
|
||||
expect(keyboard[0]).toHaveLength(1);
|
||||
expect(keyboard[0][0].text).toBe(en.products.back_to_main);
|
||||
expect(keyboard[0][0].callback_data).toBe('shop_start');
|
||||
});
|
||||
});
|
||||
|
||||
describe('BUG-03: double-tap lock', () => {
|
||||
const { tryAcquireLock, LOCK_MS } = callbackLock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
callbackLock.pruneLocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
callbackLock.pruneLocks();
|
||||
});
|
||||
|
||||
it('first acquisition succeeds, immediate second fails, after LOCK_MS succeeds', () => {
|
||||
const userId = 12345;
|
||||
|
||||
expect(tryAcquireLock(userId)).toBe(true);
|
||||
expect(tryAcquireLock(userId)).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(LOCK_MS);
|
||||
expect(tryAcquireLock(userId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BUG-04: resetUserContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deletes tracked photo/product/hidden messages and clears state', async () => {
|
||||
userStates.get.mockResolvedValue({
|
||||
photoMessageId: 1,
|
||||
productMessageId: 2,
|
||||
hiddenPhotoMessageId: 3
|
||||
});
|
||||
|
||||
await resetUserContext(123);
|
||||
|
||||
expect(bot.deleteMessage).toHaveBeenCalledWith(123, 1);
|
||||
expect(bot.deleteMessage).toHaveBeenCalledWith(123, 2);
|
||||
expect(bot.deleteMessage).toHaveBeenCalledWith(123, 3);
|
||||
expect(userStates.delete).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it('does not throw when deleteMessage rejects', async () => {
|
||||
bot.deleteMessage.mockRejectedValueOnce(new Error('cannot delete'));
|
||||
bot.deleteMessage.mockResolvedValueOnce({});
|
||||
bot.deleteMessage.mockRejectedValueOnce(new Error('cannot delete'));
|
||||
userStates.get.mockResolvedValue({
|
||||
photoMessageId: 10,
|
||||
productMessageId: 11,
|
||||
hiddenPhotoMessageId: 12
|
||||
});
|
||||
|
||||
await expect(resetUserContext(456)).resolves.toBeUndefined();
|
||||
expect(bot.deleteMessage).toHaveBeenCalledTimes(3);
|
||||
expect(userStates.delete).toHaveBeenCalledWith(456);
|
||||
});
|
||||
|
||||
it('handles empty state gracefully', async () => {
|
||||
userStates.get.mockResolvedValue({});
|
||||
|
||||
await expect(resetUserContext(789)).resolves.toBeUndefined();
|
||||
expect(bot.deleteMessage).not.toHaveBeenCalled();
|
||||
expect(userStates.delete).toHaveBeenCalledWith(789);
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,9 @@ const en = {
|
||||
infinite_stock: '∞ Always available',
|
||||
no_description: 'No description provided',
|
||||
no_photo: 'No photo available',
|
||||
error_loading_product: 'Error loading product details. Please try again.'
|
||||
error_loading_product: 'Error loading product details. Please try again.',
|
||||
location_disabled: 'This location is no longer available.',
|
||||
back_to_main: '🏠 Main Menu'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -20,7 +22,8 @@ vi.mock('../context/bot.js', () => {
|
||||
const bot = {
|
||||
deleteMessage: vi.fn(),
|
||||
sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }),
|
||||
sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 })
|
||||
sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }),
|
||||
answerCallbackQuery: vi.fn().mockResolvedValue({})
|
||||
};
|
||||
return { __esModule: true, default: bot, botAvailable: true };
|
||||
});
|
||||
@@ -30,7 +33,7 @@ vi.mock('../context/userStates.js', () => ({
|
||||
default: {
|
||||
get: vi.fn().mockResolvedValue({}),
|
||||
set: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn()
|
||||
delete: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -50,7 +53,8 @@ vi.mock('../services/productService.js', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getDetailedProductById: vi.fn(),
|
||||
getProductById: vi.fn()
|
||||
getProductById: vi.fn(),
|
||||
getProductAvailability: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -114,9 +118,19 @@ function baseProduct(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function baseAvailability(overrides = {}) {
|
||||
return {
|
||||
...baseProduct(),
|
||||
loc_active: 1,
|
||||
cat_active: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('UserProductHandler.handleProductSelection edge cases', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ProductService.getProductAvailability.mockResolvedValue(baseAvailability());
|
||||
});
|
||||
|
||||
it('renders fallback text when description and photo are missing, sends message, does not send photo', async () => {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<svg class="sa-icon sa-thin">
|
||||
<use href="/icons/sprite.svg#tag"></use>
|
||||
</svg>
|
||||
<span class="fs-xs opacity-70">v1.2.1</span>
|
||||
<span class="fs-xs opacity-70">v1.2.2</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -43,10 +43,10 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info mb-3">
|
||||
<strong>Current:</strong> v1.2.1 · 2026-07-18
|
||||
<strong>Current:</strong> v1.2.2 · 2026-08-04
|
||||
</div>
|
||||
|
||||
<h6 class="fw-bold mb-2">v1.2.1 <span class="text-muted fs-sm">— 2026-07-18</span></h6>
|
||||
<h6 class="fw-bold mb-2">v1.2.2 <span class="text-muted fs-sm">— 2026-08-04</span></h6>
|
||||
<ul class="small mb-3">
|
||||
<li><span class="badge bg-info">refactor</span> Removed deposit amount-selection step; deposit_wallet_ now goes directly to Mercuryo instructions</li>
|
||||
<li><span class="badge bg-primary">feat</span> Updated Mercuryo button text to include VISA/Mastercard branding in all locales</li>
|
||||
|
||||
@@ -5,6 +5,7 @@ import bot from "../../context/bot.js";
|
||||
import UserService from "../../services/userService.js";
|
||||
import WalletService from "../../services/walletService.js";
|
||||
import logger from "../../utils/logger.js";
|
||||
import { resetUserContext } from "../../utils/messageUtils.js";
|
||||
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
|
||||
|
||||
export default class UserHandler {
|
||||
@@ -100,6 +101,7 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
|
||||
const username = msg.chat.username;
|
||||
|
||||
try {
|
||||
await resetUserContext(chatId);
|
||||
await UserService.createUser({
|
||||
telegram_id: telegramId,
|
||||
username: username
|
||||
|
||||
@@ -187,17 +187,19 @@ export default class UserProductHandler {
|
||||
const lang = user?.language || 'en';
|
||||
const t = tForUser(lang);
|
||||
|
||||
const location = await LocationService.getLocationById(locationId);
|
||||
const location = await LocationService.getActiveLocationById(locationId);
|
||||
|
||||
if (!location) {
|
||||
await bot.answerCallbackQuery(callbackQuery.id);
|
||||
await userStates.delete(chatId);
|
||||
await bot.editMessageText(
|
||||
t('products.not_found'),
|
||||
t('products.location_disabled'),
|
||||
{
|
||||
chat_id: chatId,
|
||||
message_id: messageId,
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: t('products.back'), callback_data: `shop_city_${encodeURIComponent(location?.country || '')}|${encodeURIComponent(location?.city || '')}` }
|
||||
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||
]]
|
||||
}
|
||||
}
|
||||
@@ -411,6 +413,24 @@ export default class UserProductHandler {
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
const availability = await ProductService.getProductAvailability(productId);
|
||||
if (!availability || !availability.loc_active || !availability.cat_active) {
|
||||
await bot.answerCallbackQuery(callbackQuery.id);
|
||||
const state = await userStates.get(chatId);
|
||||
if (state?.photoMessageId) {
|
||||
try { await bot.deleteMessage(chatId, state.photoMessageId); } catch (_) {}
|
||||
}
|
||||
await userStates.delete(chatId);
|
||||
await bot.sendMessage(chatId, t('products.location_disabled'), {
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||
]]
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем предыдущее сообщение
|
||||
await bot.deleteMessage(chatId, messageId);
|
||||
@@ -633,7 +653,21 @@ export default class UserProductHandler {
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
|
||||
const availability = await ProductService.getProductAvailability(productId);
|
||||
if (!availability || !availability.loc_active || !availability.cat_active) {
|
||||
await bot.answerCallbackQuery(callbackQuery.id);
|
||||
await userStates.delete(chatId);
|
||||
await editOrSendCallback(callbackQuery, t('products.location_disabled'), {
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||
]]
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const quantity = product.is_mono ? 1 : (state?.quantity || 1);
|
||||
const totalPrice = product.price * quantity;
|
||||
|
||||
@@ -761,8 +795,22 @@ export default class UserProductHandler {
|
||||
if (!product) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
const totalPrice = product.price * quantity;
|
||||
|
||||
const availability = await ProductService.getProductAvailability(productId);
|
||||
if (!availability || !availability.loc_active || !availability.cat_active) {
|
||||
await bot.answerCallbackQuery(callbackQuery.id);
|
||||
await userStates.delete(chatId);
|
||||
await editOrSendCallback(callbackQuery, t('products.location_disabled'), {
|
||||
reply_markup: {
|
||||
inline_keyboard: [[
|
||||
{ text: t('products.back_to_main'), callback_data: 'shop_start' }
|
||||
]]
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const totalPrice = product.price * qty;
|
||||
const balance = user.total_balance + user.bonus_balance;
|
||||
|
||||
if (totalPrice > balance) {
|
||||
@@ -775,17 +823,17 @@ export default class UserProductHandler {
|
||||
}
|
||||
|
||||
// Проверка наличия товара (skip for mono products)
|
||||
if (!product.is_mono && product.quantity_in_stock < quantity) {
|
||||
if (!product.is_mono && product.quantity_in_stock < qty) {
|
||||
await editOrSendCallback(callbackQuery, t('purchase.not_enough_stock', { count: product.quantity_in_stock }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Создаем покупку и получаем её ID
|
||||
const purchaseId = await PurchaseService.createPurchase(user.id, productId, walletType, quantity, totalPrice);
|
||||
const purchaseId = await PurchaseService.createPurchase(user.id, productId, walletType, qty, totalPrice);
|
||||
|
||||
// Уменьшаем количество товара в базе данных (skip for mono products)
|
||||
if (!product.is_mono) {
|
||||
await ProductService.decreaseProductQuantity(productId, quantity);
|
||||
await ProductService.decreaseProductQuantity(productId, qty);
|
||||
}
|
||||
|
||||
// Извлекаем данные о локации
|
||||
@@ -810,7 +858,7 @@ export default class UserProductHandler {
|
||||
const message = `
|
||||
${t('purchase.details')}
|
||||
${t('purchase.product')}: ${product.name}
|
||||
${t('purchase.quantity')}: ${quantity}
|
||||
${t('purchase.quantity')}: ${qty}
|
||||
${t('purchase.total')}: $${totalPrice}
|
||||
${t('purchase.location')}: ${location?.country || 'N/A'}, ${location?.city || 'N/A'}, ${location?.district || 'N/A'}
|
||||
${t('purchase.category')}: ${category?.name || 'N/A'}
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"error_loading_categories": "Fehler beim Laden der Kategorien. Bitte versuche es erneut.",
|
||||
"error_loading_product": "Fehler beim Laden der Produktdetails. Bitte versuche es erneut.",
|
||||
"not_found": "Standort nicht gefunden. Zurück zum vorherigen Menü.",
|
||||
"location_disabled": "Dieser Standort ist nicht mehr verfügbar.",
|
||||
"back_to_main": "🏠 Hauptmenü",
|
||||
"mono_product": "📦 Digitales Produkt",
|
||||
"infinite_stock": "∞ Immer verfügbar",
|
||||
"no_description": "Keine Beschreibung",
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"error_loading_categories": "Error loading categories. Please try again.",
|
||||
"error_loading_product": "Error loading product details. Please try again.",
|
||||
"not_found": "Location not found. Returning to previous menu.",
|
||||
"location_disabled": "This location is no longer available.",
|
||||
"back_to_main": "🏠 Main Menu",
|
||||
"mono_product": "📦 Digital Product",
|
||||
"infinite_stock": "∞ Always available",
|
||||
"no_description": "No description provided",
|
||||
|
||||
@@ -54,6 +54,8 @@
|
||||
"error_loading_categories": "Error al cargar categorías. Inténtalo de nuevo.",
|
||||
"error_loading_product": "Error al cargar detalles del producto. Inténtalo de nuevo.",
|
||||
"not_found": "Ubicación no encontrada. Volviendo al menú anterior.",
|
||||
"location_disabled": "Esta ubicación ya no está disponible.",
|
||||
"back_to_main": "🏠 Menú Principal",
|
||||
"mono_product": "📦 Producto Digital",
|
||||
"infinite_stock": "∞ Siempre disponible",
|
||||
"no_description": "Sin descripción",
|
||||
|
||||
17
src/index.js
17
src/index.js
@@ -10,6 +10,12 @@ import callbackRouter from './router/callbackRouter.js';
|
||||
import messageRouter from './router/messageRouter.js';
|
||||
|
||||
import { initStates } from './services/stateService.js';
|
||||
import { resetUserContext } from './utils/messageUtils.js';
|
||||
import { tryAcquireLock, pruneLocks } from './utils/callbackLock.js';
|
||||
|
||||
setInterval(() => {
|
||||
pruneLocks();
|
||||
}, 3600_000);
|
||||
|
||||
await runMigrations();
|
||||
await cleanUpInvalidForeignKeys();
|
||||
@@ -21,6 +27,7 @@ if (bot && botAvailable) {
|
||||
const canUse = await userHandler.canUseBot(msg);
|
||||
if (!canUse) return;
|
||||
try {
|
||||
await resetUserContext(msg.chat.id);
|
||||
await userHandler.handleStart(msg);
|
||||
} catch (error) {
|
||||
await ErrorHandler.handleError(bot, msg.chat.id, error, 'start command');
|
||||
@@ -60,10 +67,20 @@ if (bot && botAvailable) {
|
||||
await bot.answerCallbackQuery(callbackQuery.id);
|
||||
return;
|
||||
}
|
||||
const userId = callbackQuery.from?.id;
|
||||
if (userId && !tryAcquireLock(userId)) {
|
||||
await bot.answerCallbackQuery(callbackQuery.id, { text: '⏳' }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
let answered = false;
|
||||
try {
|
||||
await callbackRouter.dispatch(callbackQuery);
|
||||
await bot.answerCallbackQuery(callbackQuery.id);
|
||||
answered = true;
|
||||
} catch (error) {
|
||||
if (!answered) {
|
||||
await bot.answerCallbackQuery(callbackQuery.id).catch(() => {});
|
||||
}
|
||||
await ErrorHandler.handleError(bot, callbackQuery.message.chat.id, error, 'callback query');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import callbackRouter from './callbackRouter.js';
|
||||
import messageRouter from './messageRouter.js';
|
||||
import { isAdmin } from '../middleware/auth.js';
|
||||
import logger from '../utils/logger.js';
|
||||
import { resetUserContext } from '../utils/messageUtils.js';
|
||||
|
||||
import userHandler from '../handlers/userHandlers/userHandler.js';
|
||||
import userPurchaseHandler from '../handlers/userHandlers/userPurchaseHandler.js';
|
||||
@@ -40,18 +41,22 @@ export function registerRoutes() {
|
||||
|
||||
// === Text Commands ===
|
||||
messageRouter.registerText('keyboard.products', async (msg) => {
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showProducts');
|
||||
await userProductHandler.showProducts(msg);
|
||||
});
|
||||
messageRouter.registerText('keyboard.profile', async (msg) => {
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showProfile');
|
||||
await userHandler.showProfile(msg);
|
||||
});
|
||||
messageRouter.registerText('keyboard.wallets', async (msg) => {
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showBalance');
|
||||
await userWalletsHandler.showBalance(msg);
|
||||
});
|
||||
messageRouter.registerText('keyboard.purchases', async (msg) => {
|
||||
await resetUserContext(msg.chat.id);
|
||||
logDebug(msg.text, 'showPurchases');
|
||||
await userPurchaseHandler.showPurchases(msg);
|
||||
});
|
||||
|
||||
@@ -52,6 +52,19 @@ class LocationService {
|
||||
throw new Error('Failed to fetch location');
|
||||
}
|
||||
}
|
||||
|
||||
static async getActiveLocationById(locationId) {
|
||||
try {
|
||||
const location = await db.getAsync(
|
||||
'SELECT * FROM locations WHERE id = ? AND is_active = 1',
|
||||
[locationId]
|
||||
);
|
||||
return location;
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, 'Error fetching active location by ID');
|
||||
throw new Error('Failed to fetch location');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default LocationService;
|
||||
@@ -58,6 +58,20 @@ class ProductService {
|
||||
);
|
||||
}
|
||||
|
||||
static async getProductAvailability(productId) {
|
||||
if (!Validators.isValidNumericId(Number(productId))) {
|
||||
throw new Error('Invalid product ID');
|
||||
}
|
||||
return await db.getAsync(
|
||||
`SELECT p.*, l.is_active as loc_active, c.is_active as cat_active
|
||||
FROM products p
|
||||
LEFT JOIN locations l ON p.location_id = l.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
WHERE p.id = ?`,
|
||||
[productId]
|
||||
);
|
||||
}
|
||||
|
||||
static async decreaseProductQuantity(productId, quantity) {
|
||||
if (!Validators.isValidNumericId(Number(productId))) {
|
||||
throw new Error('Invalid product ID');
|
||||
|
||||
17
src/utils/callbackLock.js
Normal file
17
src/utils/callbackLock.js
Normal file
@@ -0,0 +1,17 @@
|
||||
const callbackLocks = new Map();
|
||||
export const LOCK_MS = 1500;
|
||||
|
||||
export function tryAcquireLock(userId) {
|
||||
const now = Date.now();
|
||||
const last = callbackLocks.get(userId) || 0;
|
||||
if (now - last < LOCK_MS) return false;
|
||||
callbackLocks.set(userId, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function pruneLocks() {
|
||||
const cutoff = Date.now() - LOCK_MS;
|
||||
for (const [k, v] of callbackLocks) {
|
||||
if (v < cutoff) callbackLocks.delete(k);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,20 @@
|
||||
import bot from '../context/bot.js';
|
||||
import userStates from '../context/userStates.js';
|
||||
|
||||
export async function resetUserContext(chatId) {
|
||||
try {
|
||||
const state = await userStates.get(chatId);
|
||||
if (state) {
|
||||
const msgIds = [state.photoMessageId, state.productMessageId, state.hiddenPhotoMessageId].filter(Boolean);
|
||||
for (const msgId of msgIds) {
|
||||
try { await bot.deleteMessage(chatId, msgId); } catch (_) {}
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
await userStates.delete(chatId);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export async function editOrSend(chatId, messageId, text, options = {}) {
|
||||
if (messageId) {
|
||||
|
||||
Reference in New Issue
Block a user