diff --git a/src/bot/handlers/message.handler.test.ts b/src/bot/handlers/message.handler.test.ts index 076819b..3518e0a 100644 --- a/src/bot/handlers/message.handler.test.ts +++ b/src/bot/handlers/message.handler.test.ts @@ -115,8 +115,10 @@ mock.module('../services/expense-saver', () => ({ // ── ask (maybeSmartAdvice is fire-and-forget advice hook) ── const maybeSmartAdviceMock = mock(() => Promise.resolve()); +const handleAskQuestionMock = mock(() => Promise.resolve()); mock.module('../commands/ask', () => ({ maybeSmartAdvice: maybeSmartAdviceMock, + handleAskQuestion: handleAskQuestionMock, })); // ── dev pipeline — never trigger in these tests ── @@ -243,7 +245,9 @@ mock.module('../services/category-wizard', () => ({ })); // Dynamic-import target — must come AFTER all mock.module calls. -const { handleExpenseMessage, buildBudgetAlertStatus } = await import('./message.handler'); +const { handleExpenseMessage, buildBudgetAlertStatus, routeTextMessage } = await import( + './message.handler' +); // ─── Fixtures ──────────────────────────────────────────────────────────────── @@ -336,6 +340,7 @@ function resetAllMocks(): void { saveExpenseBatchMock.mockReset().mockResolvedValue(); saveReceiptExpensesMock.mockReset().mockResolvedValue(); maybeSmartAdviceMock.mockReset().mockResolvedValue(); + handleAskQuestionMock.mockReset().mockResolvedValue(); isAwaitingCustomCurrencyMock.mockReset().mockReturnValue(false); handleCustomCurrencyInputMock.mockReset().mockResolvedValue(true); handleWizardInputMock.mockReset().mockResolvedValue(false); @@ -791,3 +796,114 @@ describe('handleExpenseMessage — multi-line batch', () => { expect(summaryCall).toBeDefined(); }); }); + +// Budget language is intent, even if every line otherwise matches expense syntax. +describe('budget routing safety regression', () => { + beforeEach(() => { + resetAllMocks(); + mockGroups.findByTelegramGroupId.mockReturnValue(makeGroup()); + mockCategories.exists.mockReturnValue(true); + }); + + test.each([ + 'бюджет', + 'БЮДЖЕТ', + 'бюджета', + 'бюджету', + 'бюджетом', + 'бюджете', + 'бюджеты', + 'бюджетов', + 'бюджетами', + 'бюджетный', + 'budget', + 'BuDgEt', + 'BUDGETS', + ])('refuses all expense writes when %s appears in a parseable line', async (word) => { + const handled = await handleExpenseMessage( + fakeMessageCtx(`10 EUR еда\n20 EUR ${word}`), + fakeBot(), + ); + expect(handled).toBe(false); + expect(mockPendingExpenses.create).not.toHaveBeenCalled(); + expect(saveExpenseBatchMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + test.each([ + 'Составь план\n€450 — квартира', + '€450 — квартира\nУточни остаток', + '\n\n', + '10 EUR еда\nБЮДЖЕТ\n20 EUR дом', + ])('keeps the entire mixed message out of expense writes: %s', async (text) => { + expect(await handleExpenseMessage(fakeMessageCtx(text), fakeBot())).toBe(false); + expect(mockPendingExpenses.create).not.toHaveBeenCalled(); + expect(saveExpenseBatchMock).not.toHaveBeenCalled(); + }); + + test('euro prefix, em dash and blank lines remain valid for pure expenses', async () => { + expect( + await handleExpenseMessage(fakeMessageCtx('\n€450 — квартира\n\n€600 — еда\n'), fakeBot()), + ).toBe(true); + expect(mockPendingExpenses.create).toHaveBeenCalledTimes(2); + expect(saveExpenseBatchMock).toHaveBeenCalledTimes(1); + }); +}); + +describe('production text route forwards budget intent intact', () => { + beforeEach(() => { + resetAllMocks(); + mockGroups.findByTelegramGroupId.mockReturnValue(makeGroup({ active_topic_id: 77 })); + mockCategories.exists.mockReturnValue(true); + }); + + test('exact October plan reaches AI once with original context and zero expense writes', async () => { + const text = `Сформируй бюджет на грядущий октябрь + +€450 — квартира +€600 — еда +€300 — коммуналка +€200 — животные +€200 — машина +€700 — Лена +€700 — Алекс +€100 — развлечения +€620 — подписки +€100 — дом + +Другие категории убери (перечили), сумма должна быть 6000€`; + const ctx = fakeMessageCtx(text, { isForum: true, threadId: 77 }); + const bot = fakeBot(); + await routeTextMessage(ctx, bot, true); + expect(handleAskQuestionMock).toHaveBeenCalledTimes(1); + expect(handleAskQuestionMock).toHaveBeenCalledWith(ctx, text, bot); + expect(mockPendingExpenses.create).not.toHaveBeenCalled(); + expect(saveExpenseBatchMock).not.toHaveBeenCalled(); + expect(saveReceiptExpensesMock).not.toHaveBeenCalled(); + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + test.each([ + '100 EUR БЮДЖЕТА', + 'Вводный текст\n€450 — квартира', + '€450 — квартира\nЗавершающий текст', + ])('declined fastpath calls AI rather than dropping input: %s', async (text) => { + const ctx = fakeMessageCtx(text, { threadId: 77 }); + const bot = fakeBot(); + await routeTextMessage(ctx, bot, true); + expect(handleAskQuestionMock).toHaveBeenCalledTimes(1); + expect(handleAskQuestionMock).toHaveBeenCalledWith(ctx, text, bot); + expect(mockPendingExpenses.create).not.toHaveBeenCalled(); + }); + + test('pure expenses are saved without invoking AI', async () => { + await routeTextMessage( + fakeMessageCtx('€450 — квартира\n\n€600 — еда', { threadId: 77 }), + fakeBot(), + true, + ); + expect(mockPendingExpenses.create).toHaveBeenCalledTimes(2); + expect(saveExpenseBatchMock).toHaveBeenCalledTimes(1); + expect(handleAskQuestionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/bot/handlers/message.handler.ts b/src/bot/handlers/message.handler.ts index 615254a..0b12430 100644 --- a/src/bot/handlers/message.handler.ts +++ b/src/bot/handlers/message.handler.ts @@ -19,7 +19,7 @@ import { getErrorMessage } from '../../utils/error'; import { findBestCategoryMatchAsync } from '../../utils/fuzzy-search'; import { escapeHtml } from '../../utils/html'; import { createLogger } from '../../utils/logger.ts'; -import { maybeSmartAdvice } from '../commands/ask'; +import { handleAskQuestion, maybeSmartAdvice } from '../commands/ask'; import { consumePendingDesignEdit, getPipelineInstance } from '../commands/dev'; import { consumePendingFeedback, submitFeedback } from '../commands/feedback'; import { showNextPendingCategoryStep } from '../services/category-wizard'; @@ -421,6 +421,10 @@ function parseStrictLines( logger.info(`[MSG] Processing ${lines.length} line(s)`); + if (/(?:^|[^\p{L}])(?:бюджет|budget)\p{L}*(?=$|[^\p{L}])/iu.test(text)) { + logger.info('[MSG] Budget intent — routing the whole message to AI'); + return null; + } const parsedLines = lines.map((line) => parseExpenseMessage(line, defaultCurrency)); if (parsedLines.length === 0 || parsedLines.some((parsed) => !validateParsedExpense(parsed))) { logger.info('[MSG] Mixed/non-canonical message — routing the whole message to AI'); @@ -984,3 +988,15 @@ export function buildBudgetAlertStatus( isWarning: progress.is_warning, }; } + +/** Preserve the full message and context when direct expense entry declines it. */ +export async function routeTextMessage( + ctx: Ctx['Message'], + bot: BotInstance, + allowDirectAI: boolean, +): Promise { + const expenseHandled = await handleExpenseMessage(ctx, bot); + if (!expenseHandled && allowDirectAI && ctx.text) { + await handleAskQuestion(ctx, ctx.text, bot); + } +} diff --git a/src/bot/handlers/message.test-support.ts b/src/bot/handlers/message.test-support.ts index 6a76a9a..a76a2a5 100644 --- a/src/bot/handlers/message.test-support.ts +++ b/src/bot/handlers/message.test-support.ts @@ -48,8 +48,10 @@ mock.module('../services/expense-saver', () => ({ // ── ask ── export const maybeSmartAdviceMock = mock((_groupId: number) => Promise.resolve()); +export const handleAskQuestionMock = mock((_ctx: unknown, _text: string) => Promise.resolve()); mock.module('../commands/ask', () => ({ maybeSmartAdvice: maybeSmartAdviceMock, + handleAskQuestion: handleAskQuestionMock, })); // ── dev pipeline ── @@ -312,6 +314,7 @@ export function resetAllMocks(): void { saveExpenseBatchMock.mockReset().mockResolvedValue(); saveReceiptExpensesMock.mockReset().mockResolvedValue(); maybeSmartAdviceMock.mockReset().mockResolvedValue(); + handleAskQuestionMock.mockReset().mockResolvedValue(); consumePendingDesignEditMock.mockReset().mockReturnValue(null); getPipelineInstanceMock.mockReset().mockReturnValue(null); consumePendingFeedbackMock.mockReset().mockReturnValue(null); diff --git a/src/bot/index.ts b/src/bot/index.ts index e2dc5db..41ddcf7 100644 --- a/src/bot/index.ts +++ b/src/bot/index.ts @@ -38,7 +38,7 @@ import { } from './cron'; import { requireAdmin, requireGoogle, requireGroup } from './guards'; import { handleCallbackQuery } from './handlers/callback.handler'; -import { handleExpenseMessage } from './handlers/message.handler'; +import { routeTextMessage } from './handlers/message.handler'; import { handlePhotoMessage } from './handlers/photo.handler'; import { rateLimitOnResponseError, rateLimitPreRequest } from './rate-limit.hook'; import { sanitizeOutgoingMessages } from './sanitize-outgoing.hook'; @@ -196,10 +196,7 @@ export function createBot(): Bot { // Deterministic expense input is handled locally; everything else may fall through // to AI, which stores its own exact current-turn history row before reading context. - const expenseHandled = await handleExpenseMessage(ctx, bot); - if (!expenseHandled && allowDirectAI) { - await handleAskQuestion(ctx, text, bot); - } + await routeTextMessage(ctx, bot, allowDirectAI); }); // Global error handler — catches unhandled errors in bot middleware/handlers diff --git a/src/integration/expense-flow.integration.test.ts b/src/integration/expense-flow.integration.test.ts index 2159fa0..2492f4e 100644 --- a/src/integration/expense-flow.integration.test.ts +++ b/src/integration/expense-flow.integration.test.ts @@ -83,7 +83,11 @@ mock.module('../utils/fuzzy-search', () => ({ normalizeCategoryName: (s: string) => s, })); -mock.module('../bot/commands/ask', () => ({ maybeSmartAdvice: async () => {} })); +const handleAskQuestionMock = mock(async (..._args: unknown[]) => {}); +mock.module('../bot/commands/ask', () => ({ + maybeSmartAdvice: async () => {}, + handleAskQuestion: handleAskQuestionMock, +})); mock.module('../bot/commands/dev', () => ({ consumePendingDesignEdit: () => null, getPipelineInstance: () => null, @@ -162,7 +166,7 @@ mock.module('../database', () => ({ // ── Dynamic imports AFTER all mocks ────────────────────────────────────── -const { handleExpenseMessage } = await import('../bot/handlers/message.handler'); +const { handleExpenseMessage, routeTextMessage } = await import('../bot/handlers/message.handler'); // ── Fixtures ───────────────────────────────────────────────────────────── @@ -183,6 +187,7 @@ afterAll(() => { beforeEach(() => { clearTestDb(db); + handleAskQuestionMock.mockClear(); sentMessages.length = 0; sendMessageMock.mockClear(); appendExpenseRowsMock.mockReset(); @@ -336,3 +341,33 @@ describe('expense-flow integration', () => { expect(appendExpenseRowsMock).not.toHaveBeenCalled(); }); }); + +test('exact October budget replay reaches AI with zero real expense or pending rows', async () => { + seedGroupAndUser(); + const text = `Сформируй бюджет на грядущий октябрь + +€450 — квартира +€600 — еда +€300 — коммуналка +€200 — животные +€200 — машина +€700 — Лена +€700 — Алекс +€100 — развлечения +€620 — подписки +€100 — дом + +Другие категории убери (перечили), сумма должна быть 6000€`; + const ctx = fakeCtx(text); + const bot = fakeBot(); + await routeTextMessage(ctx, bot, true); + expect(handleAskQuestionMock).toHaveBeenCalledTimes(1); + expect(handleAskQuestionMock).toHaveBeenCalledWith(ctx, text, bot); + expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM expenses').get()?.n).toBe(0); + expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM pending_expenses').get()?.n).toBe( + 0, + ); + expect(appendExpenseRowsMock).not.toHaveBeenCalled(); + expect(sentMessages).toEqual([]); + expect(logMock.error).not.toHaveBeenCalled(); +});