diff --git a/apps/telegram-bot/package.json b/apps/telegram-bot/package.json index f7c9b98..0027734 100644 --- a/apps/telegram-bot/package.json +++ b/apps/telegram-bot/package.json @@ -24,6 +24,7 @@ "@opentelemetry/instrumentation-undici": "0.25.0", "@xpenser/client": "0.1.0", "@xpenser/contracts": "0.1.0", + "@xpenser/timezone": "0.1.0", "node-telegram-bot-api": "^0.66.0" }, "devDependencies": { diff --git a/apps/telegram-bot/src/bot.test.ts b/apps/telegram-bot/src/bot.test.ts new file mode 100644 index 0000000..191fcc5 --- /dev/null +++ b/apps/telegram-bot/src/bot.test.ts @@ -0,0 +1,376 @@ +import { Readable } from 'node:stream'; +import type { + Category, + Currency, + TransactionScanProgressEvent, + UserPreference, + Vendor +} from '@xpenser/contracts'; +import type TelegramBot from 'node-telegram-bot-api'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + noteSkipCallback, + scanConfirmCallback, + vendorSelectCallbackPrefix +} from './flow.js'; + +const mocks = vi.hoisted(() => { + const instances: MockTelegramBot[] = []; + const serviceClient = { + telegram: { + link: vi.fn(), + token: vi.fn() + } + }; + const userClient = { + auth: { me: vi.fn() }, + categories: { list: vi.fn() }, + currencies: { convert: vi.fn(), list: vi.fn() }, + transactions: { create: vi.fn(), list: vi.fn() }, + transactionScans: { + decide: vi.fn(), + progress: vi.fn(), + start: vi.fn() + }, + vendors: { list: vi.fn() } + }; + const createXpenserClient = vi.fn( + (options?: { readonly headers?: unknown }) => + options?.headers ? serviceClient : userClient + ); + + class MockTelegramBot { + answerCallbackQuery = vi.fn(async () => true); + editMessageText = vi.fn(async () => ({})); + getFileStream = vi.fn(() => Readable.from([])); + on = vi.fn(); + onText = vi.fn(); + sendMessage = vi.fn(async (chatId: number, text: string) => ({ + chat: { id: chatId, type: 'private' }, + date: 0, + message_id: this.sendMessage.mock.calls.length, + text + })); + setMyCommands = vi.fn(async () => true); + stopPolling = vi.fn(async () => true); + + constructor() { + instances.push(this); + } + } + + return { + MockTelegramBot, + createXpenserClient, + instances, + serviceClient, + userClient + }; +}); + +vi.mock('node-telegram-bot-api', () => ({ + default: mocks.MockTelegramBot +})); + +vi.mock('@xpenser/client', () => ({ + createXpenserClient: mocks.createXpenserClient +})); + +import { XpenserTelegramBot } from './bot.js'; + +const telegramUser = { + first_name: 'A', + id: 123, + is_bot: false, + username: 'alice' +} as TelegramBot.User; + +const baseChat = { id: 456, type: 'private' } as TelegramBot.Chat; + +const me = { + countryCode: 'US', + defaultCurrency: 'USD', + email: 'alice@example.test', + favoriteCurrencies: [], + hasCategories: true, + id: 1, + monthlyEmailReportEnabled: false, + timezone: 'UTC', + transactionCurrencies: ['USD'], + weeklyEmailReportEnabled: false +} as UserPreference; + +const categories = [ + { + id: 1, + displayName: 'Groceries', + name: 'Groceries', + type: 'expense' + }, + { + id: 2, + displayName: 'Coffee', + name: 'Coffee', + type: 'expense' + } +] as Category[]; + +const currencies = [{ code: 'USD', name: 'US Dollar' }] as Currency[]; + +const vendors = [ + { + id: 9, + displayName: 'Market', + name: 'Market', + suggestedCategoryId: 1 + } +] as Vendor[]; + +function logger() { + return { + error: vi.fn(), + forContext: vi.fn(() => logger()), + info: vi.fn() + }; +} + +function bot() { + return new XpenserTelegramBot( + { + apiBaseUrl: 'http://api.test', + logLevel: 'information', + nodeEnv: 'test', + serviceSecret: 'service-secret-minimum-32-characters', + telegram: { + token: 'telegram-token', + username: 'xpenser_bot' + } + } as never, + logger() as never + ); +} + +function message(overrides: Partial = {}) { + return { + chat: baseChat, + date: 0, + from: telegramUser, + message_id: 10, + ...overrides + } as TelegramBot.Message; +} + +function callback(data: string): TelegramBot.CallbackQuery { + return { + chat_instance: 'chat', + data, + from: telegramUser, + id: `query-${data}`, + message: message() + } as TelegramBot.CallbackQuery; +} + +function progressSubscription(events: readonly TransactionScanProgressEvent[]) { + return { + close: vi.fn(), + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event; + } + } + }; +} + +function transaction(overrides: Record = {}) { + return { + amount: 12.5, + categoryDisplayName: 'Groceries', + categoryKind: 'normal', + currency: 'USD', + defaultCurrency: 'USD', + defaultCurrencyAmount: 12.5, + id: 77, + type: 'expense', + vendorName: 'Market', + ...overrides + }; +} + +beforeEach(() => { + mocks.instances.length = 0; + vi.clearAllMocks(); + mocks.createXpenserClient.mockImplementation( + (options?: { readonly headers?: unknown }) => + options?.headers ? mocks.serviceClient : mocks.userClient + ); + mocks.serviceClient.telegram.token.mockResolvedValue({ + token: 'user-token' + }); + mocks.userClient.auth.me.mockResolvedValue(me); + mocks.userClient.categories.list.mockResolvedValue(categories); + mocks.userClient.currencies.list.mockResolvedValue(currencies); + mocks.userClient.transactions.list.mockResolvedValue({ items: [] }); + mocks.userClient.transactions.create.mockResolvedValue(transaction()); + mocks.userClient.vendors.list.mockResolvedValue(vendors); +}); + +describe('XpenserTelegramBot transaction flows', () => { + it('adds an existing vendor to manual Telegram transactions', async () => { + const subject = bot(); + + await subject.beginAdd(message({ text: '/add' })); + await subject.handleText(message({ text: '12.50' })); + await subject.handleCallback(callback('cur:USD')); + await subject.handleCallback( + callback(`${vendorSelectCallbackPrefix}${vendors[0]?.id}`) + ); + await subject.handleCallback(callback('cat:1')); + await subject.handleCallback(callback(noteSkipCallback)); + + expect(mocks.userClient.transactions.create).toHaveBeenCalledWith({ + body: expect.objectContaining({ + amount: 12.5, + categoryId: 1, + currency: 'USD', + vendorId: 9 + }) + }); + }); + + it('scans a Telegram photo and confirms the scanned draft with attachment', async () => { + const subject = bot(); + const telegram = mocks.instances[0]!; + telegram.getFileStream.mockReturnValue( + Readable.from([Buffer.from('receipt bytes')]) + ); + mocks.userClient.transactionScans.start.mockResolvedValue({ + jobId: 'job-1', + token: 'scan-token' + }); + mocks.userClient.transactionScans.progress.mockReturnValue( + progressSubscription([ + { + error: null, + jobId: 'job-1', + message: 'Scan queued.', + progress: 0, + scan: null, + stage: 'queued' + }, + { + error: null, + jobId: 'job-1', + message: 'Found 1 transaction for review.', + progress: 100, + scan: { + drafts: [ + { + amount: 12.5, + categoryId: 1, + confidence: { + amount: 'high', + category: 'high', + currency: 'high', + date: 'high', + overall: 'high', + vendor: 'high' + }, + currency: 'USD', + evidence: 'Market total 12.50', + id: 50, + note: 'Scanned invoice', + occurredAt: new Date( + '2026-06-06T12:00:00.000Z' + ), + possibleDuplicateTransactionIds: [], + suggestedCategory: null, + suggestedVendorName: null, + transactionType: 'expense', + vendorId: 9 + } + ], + documentKind: 'invoice', + scanId: 40, + warnings: [] + }, + stage: 'complete' + } + ]) + ); + + await subject.handleScanImage( + message({ + photo: [ + { + file_id: 'small', + file_size: 5, + file_unique_id: 'small-u', + height: 100, + width: 100 + }, + { + file_id: 'large', + file_size: 20, + file_unique_id: 'large-u', + height: 1000, + width: 1000 + } + ] + }) + ); + await subject.handleCallback(callback(scanConfirmCallback)); + + expect(telegram.getFileStream).toHaveBeenCalledWith('large'); + expect(mocks.userClient.transactionScans.start).toHaveBeenCalledWith({ + body: { + fileName: 'telegram-photo-10.jpg', + imageBase64: Buffer.from('receipt bytes').toString('base64'), + mimeType: 'image/jpeg' + } + }); + expect(mocks.userClient.transactions.create).toHaveBeenCalledWith({ + body: expect.objectContaining({ + amount: 12.5, + categoryId: 1, + currency: 'USD', + vendorId: 9 + }) + }); + expect(mocks.userClient.transactionScans.decide).toHaveBeenCalledWith({ + params: { itemId: 50, scanId: 40 }, + body: expect.objectContaining({ + attachment: { + fileName: 'telegram-photo-10.jpg', + imageBase64: + Buffer.from('receipt bytes').toString('base64'), + mimeType: 'image/jpeg' + }, + decision: 'confirmed', + transactionId: 77 + }) + }); + }); + + it('rejects unsupported Telegram documents before scanning', async () => { + const subject = bot(); + + await subject.handleScanImage( + message({ + document: { + file_id: 'pdf', + file_name: 'invoice.pdf', + file_size: 100, + file_unique_id: 'pdf-u', + mime_type: 'application/pdf' + } as TelegramBot.Document + }) + ); + + expect(mocks.userClient.transactionScans.start).not.toHaveBeenCalled(); + expect(mocks.instances[0]?.sendMessage).toHaveBeenCalledWith( + baseChat.id, + 'Upload a PNG, JPEG, or WebP image.', + expect.anything() + ); + }); +}); diff --git a/apps/telegram-bot/src/bot.ts b/apps/telegram-bot/src/bot.ts index 7535fbf..ef8d761 100644 --- a/apps/telegram-bot/src/bot.ts +++ b/apps/telegram-bot/src/bot.ts @@ -1,20 +1,60 @@ +import { Readable } from 'node:stream'; import type { Logger } from '@cleverbrush/log'; import { createXpenserClient, type XpenserClient } from '@xpenser/client'; -import type { Category, Currency, UserPreference } from '@xpenser/contracts'; +import type { + Category, + Currency, + Transaction, + TransactionScanBody, + TransactionScanDraft, + TransactionScanJobResponse, + TransactionScanProgressEvent, + TransactionScanResponse, + UserPreference, + Vendor +} from '@xpenser/contracts'; +import { FieldLimits, TransactionScanLimits } from '@xpenser/contracts'; +import { + dateToLocalDateTimeInput, + formatDateInTimeZone +} from '@xpenser/timezone'; import TelegramBot from 'node-telegram-bot-api'; import type { BotConfig } from './config.js'; import { addCommand, cancelCallback, categoriesByRecentUse, + categoriesWithPreferredFirst, currencyKeyboard, + draftCategoryType, + filteredVendors, isAddButtonText, + isAllowedScanImageMimeType, noteAddCallback, + noteLengthError, noteSkipCallback, parseAmount, parseStartToken, + parseTelegramDateTime, preferredCurrencies, - quickAddReplyKeyboard + quickAddReplyKeyboard, + scanConfirmCallback, + scanDiscardCallback, + scanEditAmountCallback, + scanEditCategoryCallback, + scanEditCurrencyCallback, + scanEditDateCallback, + scanEditNoteCallback, + scanEditVendorCallback, + scanImageSizeError, + scanNextCallback, + scanPreviousCallback, + vendorKeyboard, + vendorLabel, + vendorNoneCallback, + vendorPageCallbackPrefix, + vendorSearchCallback, + vendorSelectCallbackPrefix } from './flow.js'; import { TelegramPollingError } from './log-templates.js'; import { @@ -30,42 +70,122 @@ type TelegramUserBody = { readonly telegramLastName?: string; }; -type Draft = +type ManualDraft = | { + readonly kind: 'manual'; readonly step: 'amount'; readonly me: UserPreference; readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; + readonly vendors: readonly Vendor[]; } | { + readonly kind: 'manual'; readonly step: 'currency'; readonly me: UserPreference; readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; + readonly vendors: readonly Vendor[]; + readonly amount: number; + } + | { + readonly kind: 'manual'; + readonly step: 'vendor'; + readonly me: UserPreference; + readonly categories: readonly Category[]; + readonly currencies: readonly Currency[]; + readonly vendors: readonly Vendor[]; + readonly amount: number; + readonly currency: string; + readonly page: number; + readonly query?: string; + } + | { + readonly kind: 'manual'; + readonly step: 'vendor-search'; + readonly me: UserPreference; + readonly categories: readonly Category[]; + readonly currencies: readonly Currency[]; + readonly vendors: readonly Vendor[]; readonly amount: number; + readonly currency: string; } | { + readonly kind: 'manual'; readonly step: 'category'; readonly me: UserPreference; readonly categories: readonly Category[]; readonly currencies: readonly Currency[]; readonly amount: number; readonly currency: string; + readonly vendorId: number | null; readonly page: number; } | { + readonly kind: 'manual'; readonly step: 'note-choice'; readonly category: Category; readonly currency: string; readonly amount: number; + readonly vendorId: number | null; } | { + readonly kind: 'manual'; readonly step: 'note-text'; readonly category: Category; readonly currency: string; readonly amount: number; + readonly vendorId: number | null; }; +type ScanEditStep = + | 'edit-amount' + | 'edit-category' + | 'edit-currency' + | 'edit-date' + | 'edit-note' + | 'edit-vendor' + | 'edit-vendor-search'; + +type ScanDraftValues = { + readonly amount: number | null; + readonly categoryId: number | null; + readonly currency: string; + readonly occurredAt: Date; + readonly note: string; + readonly transactionType: Category['type']; + readonly vendorId: number | null; +}; + +type ScanDraftDecision = 'confirmed' | 'discarded'; + +type ScanDraftSession = { + readonly kind: 'scan'; + readonly step: 'review' | ScanEditStep; + readonly me: UserPreference; + readonly categories: readonly Category[]; + readonly currencies: readonly Currency[]; + readonly vendors: readonly Vendor[]; + readonly scan: TransactionScanResponse; + readonly attachment: TransactionScanBody; + readonly values: Readonly>; + readonly decisions: Readonly>; + readonly index: number; + readonly attachmentSubmitted: boolean; + readonly categoryPage: number; + readonly vendorPage: number; + readonly vendorQuery?: string; +}; + +type Draft = ManualDraft | ScanDraftSession; + +type TelegramScanMedia = { + readonly fileId: string; + readonly fileName?: string; + readonly fileSize?: number; + readonly mimeType: TransactionScanBody['mimeType']; +}; + const categoryPageSize = 8; const pollingErrorLogIntervalMs = 60_000; @@ -111,13 +231,13 @@ function categoryKeyboard(categories: readonly Category[], page: number) { const navigation = []; if (safePage > 0) { navigation.push({ - text: '◀ Previous', + text: 'Previous', callback_data: `catpage:${safePage - 1}` }); } if (safePage < pageCount - 1) { navigation.push({ - text: 'Next ▶', + text: 'Next', callback_data: `catpage:${safePage + 1}` }); } @@ -141,6 +261,57 @@ function noteKeyboard() { }; } +function scanReviewKeyboard(session: ScanDraftSession) { + const draft = session.scan.drafts[session.index]; + const rows = + draft && session.decisions[draft.id] + ? [] + : [ + [ + { + text: 'Confirm and save', + callback_data: scanConfirmCallback + }, + { text: 'Discard', callback_data: scanDiscardCallback } + ], + [ + { text: 'Amount', callback_data: scanEditAmountCallback }, + { + text: 'Currency', + callback_data: scanEditCurrencyCallback + } + ], + [ + { + text: 'Category', + callback_data: scanEditCategoryCallback + }, + { text: 'Vendor', callback_data: scanEditVendorCallback } + ], + [ + { text: 'Date', callback_data: scanEditDateCallback }, + { text: 'Note', callback_data: scanEditNoteCallback } + ] + ]; + + const navigation = []; + if (session.index > 0) { + navigation.push({ + text: 'Previous', + callback_data: scanPreviousCallback + }); + } + if (session.index < session.scan.drafts.length - 1) { + navigation.push({ text: 'Next', callback_data: scanNextCallback }); + } + if (navigation.length > 0) { + rows.push(navigation); + } + rows.push([{ text: 'Cancel', callback_data: cancelCallback }]); + + return { inline_keyboard: rows }; +} + function formatTelegramAmount(amount: number, currency: string): string { return `${amount.toLocaleString('en-US', { maximumFractionDigits: 2, @@ -148,6 +319,13 @@ function formatTelegramAmount(amount: number, currency: string): string { })} ${currency}`; } +function formatTelegramDate(value: Date, timeZone: string): string { + return formatDateInTimeZone(value, timeZone, { + dateStyle: 'short', + timeStyle: 'short' + }); +} + function rateDate(value = new Date()): string { return value.toISOString().slice(0, 10); } @@ -172,6 +350,165 @@ function toError(err: unknown): Error { return err instanceof Error ? err : new Error(String(err)); } +function photoMedia(msg: TelegramBot.Message): TelegramScanMedia | undefined { + const photo = [...(msg.photo ?? [])].sort((left, right) => { + const rightSize = right.file_size ?? 0; + const leftSize = left.file_size ?? 0; + return ( + rightSize - leftSize || + right.width * right.height - left.width * left.height + ); + })[0]; + if (!photo) { + return undefined; + } + + return { + fileId: photo.file_id, + fileName: `telegram-photo-${msg.message_id}.jpg`, + fileSize: photo.file_size, + mimeType: 'image/jpeg' + }; +} + +function documentMedia( + msg: TelegramBot.Message +): TelegramScanMedia | undefined { + const document = msg.document; + const mimeType = document?.mime_type; + if (!document || !isAllowedScanImageMimeType(mimeType)) { + return undefined; + } + + return { + fileId: document.file_id, + fileName: document.file_name, + fileSize: document.file_size, + mimeType + }; +} + +function scanMedia(msg: TelegramBot.Message): TelegramScanMedia | undefined { + return photoMedia(msg) ?? documentMedia(msg); +} + +function scanProgressMessage(event: TransactionScanProgressEvent): string { + return `${event.message} ${Math.max(0, Math.min(100, Math.round(event.progress)))}%`; +} + +function categoryById( + categories: readonly Category[], + categoryId: number | null +): Category | undefined { + return categoryId + ? categories.find(category => category.id === categoryId) + : undefined; +} + +function vendorById( + vendors: readonly Vendor[], + vendorId: number | null +): Vendor | undefined { + return vendorId + ? vendors.find(vendor => vendor.id === vendorId) + : undefined; +} + +function preferredCurrency( + me: UserPreference, + currencies: readonly Currency[], + value: string | null | undefined +): string { + const options = preferredCurrencies(me, currencies); + const normalized = value?.trim().toUpperCase(); + return normalized && options.includes(normalized) + ? normalized + : (options[0] ?? me.defaultCurrency); +} + +function initialScanValues( + draft: TransactionScanDraft, + me: UserPreference, + categories: readonly Category[], + currencies: readonly Currency[] +): ScanDraftValues { + const transactionType = draftCategoryType(draft, categories); + return { + amount: draft.amount, + categoryId: draft.categoryId, + currency: preferredCurrency(me, currencies, draft.currency), + occurredAt: draft.occurredAt ?? new Date(), + note: draft.note ?? '', + transactionType, + vendorId: draft.vendorId + }; +} + +function valuesForScan( + scan: TransactionScanResponse, + me: UserPreference, + categories: readonly Category[], + currencies: readonly Currency[] +): Record { + return Object.fromEntries( + scan.drafts.map(draft => [ + draft.id, + initialScanValues(draft, me, categories, currencies) + ]) + ); +} + +function scanDraftAt( + session: ScanDraftSession +): TransactionScanDraft | undefined { + return session.scan.drafts[session.index]; +} + +function scanValuesAt(session: ScanDraftSession): ScanDraftValues | undefined { + const draft = scanDraftAt(session); + return draft ? session.values[draft.id] : undefined; +} + +function updateScanValues( + session: ScanDraftSession, + draftId: number, + values: ScanDraftValues +): ScanDraftSession { + return { + ...session, + values: { + ...session.values, + [draftId]: values + } + }; +} + +function nextPendingScanIndex(session: ScanDraftSession): number | undefined { + const afterCurrent = session.scan.drafts.findIndex( + (draft, index) => index > session.index && !session.decisions[draft.id] + ); + if (afterCurrent >= 0) { + return afterCurrent; + } + + const beforeCurrent = session.scan.drafts.findIndex( + draft => !session.decisions[draft.id] + ); + return beforeCurrent >= 0 ? beforeCurrent : undefined; +} + +function savedTransactionSummary(transaction: Transaction): string { + const vendor = transaction.vendorName ? `${transaction.vendorName}, ` : ''; + return `${transaction.type}: ${vendor}${transaction.categoryDisplayName}, ${formatTelegramAmount(transaction.amount, transaction.currency)} (${formatTelegramAmount(transaction.defaultCurrencyAmount, transaction.defaultCurrency)}).`; +} + +function fileSizeLabel(size: number): string { + if (size >= 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1)} MB`; + } + return `${Math.ceil(size / 1024)} KB`; +} + export class XpenserTelegramBot { readonly #bot: TelegramBot; readonly #serviceClient: XpenserClient; @@ -243,6 +580,18 @@ export class XpenserTelegramBot { ); }); this.#bot.on('message', msg => { + if (msg.photo || msg.document) { + void traceTelegramUpdate( + { + updateType: 'message', + chatType: msg.chat.type, + messageId: msg.message_id + }, + () => this.handleScanImage(msg) + ); + return; + } + if (!msg.text || msg.text.startsWith('/')) { return; } @@ -326,7 +675,7 @@ export class XpenserTelegramBot { if (!token) { await this.#bot.sendMessage( msg.chat.id, - 'Hi! Tap Add when you want to record a transaction.', + 'Hi! Tap Add for a manual transaction, or send an invoice/receipt image to scan it.', { reply_markup: quickAddReplyKeyboard() } @@ -340,7 +689,7 @@ export class XpenserTelegramBot { }); await this.#bot.sendMessage( msg.chat.id, - `✅ Telegram connected to xpenser account ${result.email}. Tap Add to create a transaction.`, + `✅ Telegram connected to xpenser account ${result.email}. Tap Add for a manual transaction, or send an invoice/receipt image to scan it.`, { reply_markup: quickAddReplyKeyboard() } @@ -383,11 +732,12 @@ export class XpenserTelegramBot { try { const client = await this.userClient(user); - const [me, categories, currencies, recentTransactions] = + const [me, categories, currencies, vendors, recentTransactions] = await Promise.all([ client.auth.me(), client.categories.list({ query: {} }), client.currencies.list(), + client.vendors.list({ query: { limit: 100 } }), client.transactions.list({ query: { direction: 'desc', limit: 100, page: 1 } }) @@ -416,13 +766,15 @@ export class XpenserTelegramBot { } const draft: Draft = { + kind: 'manual', step: 'amount', me, categories: categoriesByRecentUse( categories, recentTransactions.items ), - currencies + currencies, + vendors }; this.#sessions.set( sessionKey(msg.chat.id, user.telegramUserId), @@ -444,6 +796,142 @@ export class XpenserTelegramBot { } } + async handleScanImage(msg: TelegramBot.Message): Promise { + if (!this.isPrivateChat(msg.chat)) { + await this.#bot.sendMessage( + msg.chat.id, + 'Please open a private chat with this bot.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + const user = telegramUser(msg.from); + if (!user) { + await this.#bot.sendMessage( + msg.chat.id, + 'Could not read Telegram user.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + const media = scanMedia(msg); + if (!media) { + await this.#bot.sendMessage( + msg.chat.id, + 'Upload a PNG, JPEG, or WebP image.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + const sizeError = scanImageSizeError(media.fileSize); + if (sizeError) { + await this.#bot.sendMessage(msg.chat.id, sizeError, { + reply_markup: quickAddReplyKeyboard() + }); + return; + } + + const progressMessage = await this.#bot.sendMessage( + msg.chat.id, + media.fileSize + ? `Downloading ${fileSizeLabel(media.fileSize)} image.` + : 'Downloading image.' + ); + + try { + const client = await this.userClient(user); + const [me, categories, currencies, vendors] = await Promise.all([ + client.auth.me(), + client.categories.list({ query: { activeOnly: true } }), + client.currencies.list(), + client.vendors.list({ query: { limit: 100 } }) + ]); + + if (categories.length === 0) { + await this.updateProgressMessage( + msg.chat.id, + progressMessage.message_id, + '🏷 Create at least one category in xpenser first.' + ); + return; + } + + const image = await this.downloadTelegramFile(media); + const body: TransactionScanBody = { + fileName: media.fileName, + imageBase64: image.toString('base64'), + mimeType: media.mimeType + }; + await this.updateProgressMessage( + msg.chat.id, + progressMessage.message_id, + 'Scan queued.' + ); + const job = await client.transactionScans.start({ body }); + const scan = await this.waitForScanJob(client, job, async event => { + await this.updateProgressMessage( + msg.chat.id, + progressMessage.message_id, + scanProgressMessage(event) + ); + }); + + if (scan.drafts.length === 0) { + await this.updateProgressMessage( + msg.chat.id, + progressMessage.message_id, + 'No transactions found. Try a clearer receipt, invoice, or banking screenshot.' + ); + return; + } + + const key = sessionKey(msg.chat.id, user.telegramUserId); + const session: ScanDraftSession = { + kind: 'scan', + step: 'review', + me, + categories, + currencies, + vendors, + scan, + attachment: body, + values: valuesForScan(scan, me, categories, currencies), + decisions: {}, + index: 0, + attachmentSubmitted: false, + categoryPage: 0, + vendorPage: 0 + }; + this.#sessions.set(key, session); + await this.updateProgressMessage( + msg.chat.id, + progressMessage.message_id, + scan.drafts.length === 1 + ? 'Found 1 transaction for review.' + : `Found ${scan.drafts.length} transactions for review.` + ); + await this.sendScanReviewPrompt(msg.chat.id, session); + } catch (err) { + await this.updateProgressMessage( + msg.chat.id, + progressMessage.message_id, + apiErrorMessage(err) ?? + (err instanceof Error + ? err.message + : 'Could not scan the image. Try again.') + ); + } + } + async cancel(msg: TelegramBot.Message): Promise { const user = telegramUser(msg.from); if (user) { @@ -475,15 +963,77 @@ export class XpenserTelegramBot { const draft = this.#sessions.get(key); if (!draft) { - await this.#bot.sendMessage(chatId, 'Tap Add to start.', { - reply_markup: quickAddReplyKeyboard() - }); + await this.#bot.sendMessage( + chatId, + 'Tap Add to start, or send an image to scan.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + if (draft.kind === 'scan') { + await this.handleScanCallback(chatId, user, key, draft, data); + return; + } + + await this.handleManualCallback(chatId, user, key, draft, data); + } + + async handleManualCallback( + chatId: number, + user: TelegramUserBody, + key: string, + draft: ManualDraft, + data: string + ): Promise { + if ( + draft.step === 'vendor' && + data.startsWith(vendorPageCallbackPrefix) + ) { + const page = Number(data.slice(vendorPageCallbackPrefix.length)); + const next: ManualDraft = { ...draft, page }; + this.#sessions.set(key, next); + await this.sendVendorPrompt(chatId, next); + return; + } + + if (draft.step === 'vendor' && data === vendorSearchCallback) { + this.#sessions.set(key, { ...draft, step: 'vendor-search' }); + await this.#bot.sendMessage( + chatId, + '🏬 Send vendor search text, or /cancel to stop.' + ); + return; + } + + if (draft.step === 'vendor' && data === vendorNoneCallback) { + await this.setManualVendor(chatId, key, draft, null); + return; + } + + if ( + draft.step === 'vendor' && + data.startsWith(vendorSelectCallbackPrefix) + ) { + const vendorId = Number( + data.slice(vendorSelectCallbackPrefix.length) + ); + if (!draft.vendors.some(vendor => vendor.id === vendorId)) { + await this.#bot.sendMessage( + chatId, + 'Vendor is no longer available.' + ); + return; + } + await this.setManualVendor(chatId, key, draft, vendorId); return; } if (draft.step === 'category' && data.startsWith('catpage:')) { const page = Number(data.slice('catpage:'.length)); - const next: Draft = { ...draft, page }; + const next: ManualDraft = { ...draft, page }; this.#sessions.set(key, next); await this.sendCategoryPrompt(chatId, next); return; @@ -503,9 +1053,11 @@ export class XpenserTelegramBot { } this.#sessions.set(key, { + kind: 'manual', step: 'note-choice', amount: draft.amount, currency: draft.currency, + vendorId: draft.vendorId, category }); await this.askForDescription(chatId); @@ -535,7 +1087,7 @@ export class XpenserTelegramBot { }); await this.#bot.sendMessage( chatId, - '📝 Send the description. Keep it under 500 characters.' + `📝 Send the description. Keep it under ${FieldLimits.transactionNote} characters.` ); return; } @@ -555,6 +1107,15 @@ export class XpenserTelegramBot { return; } + if (draft.step === 'vendor' || draft.step === 'vendor-search') { + await this.sendVendorPrompt(chatId, { + ...draft, + step: 'vendor', + page: 0 + }); + return; + } + if (draft.step === 'category') { await this.sendCategoryPrompt(chatId, draft); return; @@ -573,40 +1134,334 @@ export class XpenserTelegramBot { } } - async handleText(msg: TelegramBot.Message): Promise { - const user = telegramUser(msg.from); - const text = msg.text?.trim(); - if (!user || !text) { + async handleScanCallback( + chatId: number, + user: TelegramUserBody, + key: string, + session: ScanDraftSession, + data: string + ): Promise { + const draft = scanDraftAt(session); + const values = scanValuesAt(session); + if (!draft || !values) { + this.#sessions.delete(key); + await this.#bot.sendMessage(chatId, 'Scan session expired.', { + reply_markup: quickAddReplyKeyboard() + }); return; } - const key = sessionKey(msg.chat.id, user.telegramUserId); - const draft = this.#sessions.get(key); - if (!draft) { - await this.#bot.sendMessage(msg.chat.id, 'Tap Add to start.', { - reply_markup: quickAddReplyKeyboard() - }); + if ( + session.decisions[draft.id] && + (data === scanConfirmCallback || data === scanDiscardCallback) + ) { + await this.#bot.sendMessage( + chatId, + 'This scanned transaction was already reviewed.' + ); return; } - if (draft.step === 'amount') { - const amount = parseAmount(text); - if (!amount) { - await this.#bot.sendMessage( - msg.chat.id, - '💸 Enter a positive amount, for example 12.50.' - ); - return; - } - const next: Draft = { - step: 'currency', - me: draft.me, - categories: draft.categories, - currencies: draft.currencies, - amount + if (data === scanConfirmCallback) { + await this.confirmScanDraft( + chatId, + user, + key, + session, + draft, + values + ); + return; + } + + if (data === scanDiscardCallback) { + await this.discardScanDraft(chatId, user, key, session, draft); + return; + } + + if (data === scanPreviousCallback || data === scanNextCallback) { + const delta = data === scanPreviousCallback ? -1 : 1; + const index = Math.min( + Math.max(session.index + delta, 0), + session.scan.drafts.length - 1 + ); + const next: ScanDraftSession = { + ...session, + step: 'review', + index, + categoryPage: 0, + vendorPage: 0, + vendorQuery: undefined + }; + this.#sessions.set(key, next); + await this.sendScanReviewPrompt(chatId, next); + return; + } + + if (data === scanEditAmountCallback) { + const next: ScanDraftSession = { ...session, step: 'edit-amount' }; + this.#sessions.set(key, next); + await this.#bot.sendMessage( + chatId, + '💸 Send the amount, for example 12.50.' + ); + return; + } + + if (data === scanEditCurrencyCallback) { + const next: ScanDraftSession = { + ...session, + step: 'edit-currency' + }; + this.#sessions.set(key, next); + await this.#bot.sendMessage(chatId, '💱 Choose currency.', { + reply_markup: currencyKeyboard(session.me, session.currencies) + }); + return; + } + + if (data === scanEditCategoryCallback) { + const next: ScanDraftSession = { + ...session, + step: 'edit-category', + categoryPage: 0 + }; + this.#sessions.set(key, next); + await this.#bot.sendMessage(chatId, '🏷 Choose a category.', { + reply_markup: categoryKeyboard(session.categories, 0) + }); + return; + } + + if (data === scanEditVendorCallback) { + const next: ScanDraftSession = { + ...session, + step: 'edit-vendor', + vendorPage: 0, + vendorQuery: undefined + }; + this.#sessions.set(key, next); + await this.sendScanVendorPrompt(chatId, next); + return; + } + + if (data === scanEditDateCallback) { + const next: ScanDraftSession = { ...session, step: 'edit-date' }; + this.#sessions.set(key, next); + await this.#bot.sendMessage( + chatId, + `📅 Send date and time as YYYY-MM-DD HH:mm. Current value: ${dateToLocalDateTimeInput(values.occurredAt, session.me.timezone).replace('T', ' ')}.` + ); + return; + } + + if (data === scanEditNoteCallback) { + const next: ScanDraftSession = { ...session, step: 'edit-note' }; + this.#sessions.set(key, next); + await this.#bot.sendMessage( + chatId, + '📝 Send the note text, or send - to clear it.' + ); + return; + } + + if (session.step === 'edit-category' && data.startsWith('catpage:')) { + const page = Number(data.slice('catpage:'.length)); + const next: ScanDraftSession = { ...session, categoryPage: page }; + this.#sessions.set(key, next); + await this.#bot.sendMessage(chatId, '🏷 Choose a category.', { + reply_markup: categoryKeyboard(session.categories, page) + }); + return; + } + + if (session.step === 'edit-category' && data.startsWith('cat:')) { + const categoryId = Number(data.slice('cat:'.length)); + const category = session.categories.find( + item => item.id === categoryId + ); + if (!category) { + await this.#bot.sendMessage( + chatId, + 'Category is no longer available.' + ); + return; + } + const next = updateScanValues(session, draft.id, { + ...values, + categoryId, + transactionType: category.type + }); + const review: ScanDraftSession = { + ...next, + step: 'review', + categoryPage: 0 + }; + this.#sessions.set(key, review); + await this.sendScanReviewPrompt(chatId, review); + return; + } + + if (session.step === 'edit-currency' && data.startsWith('cur:')) { + const currency = data.slice('cur:'.length).trim().toUpperCase(); + if ( + !preferredCurrencies(session.me, session.currencies).includes( + currency + ) + ) { + await this.#bot.sendMessage( + chatId, + 'Currency is no longer available.' + ); + return; + } + const review: ScanDraftSession = { + ...updateScanValues(session, draft.id, { ...values, currency }), + step: 'review' + }; + this.#sessions.set(key, review); + await this.sendScanReviewPrompt(chatId, review); + return; + } + + if ( + session.step === 'edit-vendor' && + data.startsWith(vendorPageCallbackPrefix) + ) { + const page = Number(data.slice(vendorPageCallbackPrefix.length)); + const next: ScanDraftSession = { ...session, vendorPage: page }; + this.#sessions.set(key, next); + await this.sendScanVendorPrompt(chatId, next); + return; + } + + if (session.step === 'edit-vendor' && data === vendorSearchCallback) { + const next: ScanDraftSession = { + ...session, + step: 'edit-vendor-search' + }; + this.#sessions.set(key, next); + await this.#bot.sendMessage( + chatId, + '🏬 Send vendor search text, or /cancel to stop.' + ); + return; + } + + if (session.step === 'edit-vendor' && data === vendorNoneCallback) { + const review: ScanDraftSession = { + ...updateScanValues(session, draft.id, { + ...values, + vendorId: null + }), + step: 'review', + vendorPage: 0, + vendorQuery: undefined + }; + this.#sessions.set(key, review); + await this.sendScanReviewPrompt(chatId, review); + return; + } + + if ( + session.step === 'edit-vendor' && + data.startsWith(vendorSelectCallbackPrefix) + ) { + const vendorId = Number( + data.slice(vendorSelectCallbackPrefix.length) + ); + const vendor = session.vendors.find(item => item.id === vendorId); + if (!vendor) { + await this.#bot.sendMessage( + chatId, + 'Vendor is no longer available.' + ); + return; + } + const nextValues: ScanDraftValues = { + ...values, + vendorId + }; + const category = vendor.suggestedCategoryId + ? categoryById(session.categories, vendor.suggestedCategoryId) + : undefined; + const review: ScanDraftSession = { + ...updateScanValues(session, draft.id, { + ...nextValues, + ...(category + ? { + categoryId: category.id, + transactionType: category.type + } + : {}) + }), + step: 'review', + vendorPage: 0, + vendorQuery: undefined + }; + this.#sessions.set(key, review); + await this.sendScanReviewPrompt(chatId, review); + return; + } + + await this.sendScanReviewPrompt(chatId, session); + } + + async handleText(msg: TelegramBot.Message): Promise { + const user = telegramUser(msg.from); + const text = msg.text?.trim(); + if (!user || !text) { + return; + } + + const key = sessionKey(msg.chat.id, user.telegramUserId); + const draft = this.#sessions.get(key); + if (!draft) { + await this.#bot.sendMessage( + msg.chat.id, + 'Tap Add to start, or send an image to scan.', + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + if (draft.kind === 'scan') { + await this.handleScanText(msg.chat.id, key, draft, text); + return; + } + + await this.handleManualText(msg.chat.id, user, key, draft, text); + } + + async handleManualText( + chatId: number, + user: TelegramUserBody, + key: string, + draft: ManualDraft, + text: string + ): Promise { + if (draft.step === 'amount') { + const amount = parseAmount(text); + if (!amount) { + await this.#bot.sendMessage( + chatId, + '💸 Enter a positive amount, for example 12.50.' + ); + return; + } + const next: ManualDraft = { + kind: 'manual', + step: 'currency', + me: draft.me, + categories: draft.categories, + currencies: draft.currencies, + vendors: draft.vendors, + amount }; this.#sessions.set(key, next); - await this.#bot.sendMessage(msg.chat.id, '💱 Choose currency.', { + await this.#bot.sendMessage(chatId, '💱 Choose currency.', { reply_markup: currencyKeyboard(draft.me, draft.currencies) }); return; @@ -614,7 +1469,7 @@ export class XpenserTelegramBot { if (draft.step === 'currency') { await this.#bot.sendMessage( - msg.chat.id, + chatId, '💱 Please choose a currency button.', { reply_markup: currencyKeyboard(draft.me, draft.currencies) @@ -623,9 +1478,37 @@ export class XpenserTelegramBot { return; } + if (draft.step === 'vendor-search') { + const matches = filteredVendors(draft.vendors, text); + const next: ManualDraft = { + ...draft, + step: 'vendor', + page: 0, + query: text + }; + this.#sessions.set(key, next); + await this.#bot.sendMessage( + chatId, + matches.length === 0 + ? 'No matching vendors. Choose No vendor or search again.' + : '🏬 Choose a vendor.', + { + reply_markup: vendorKeyboard(matches, 0, { + includeSearch: true + }) + } + ); + return; + } + + if (draft.step === 'vendor') { + await this.sendVendorPrompt(chatId, draft); + return; + } + if (draft.step === 'category') { await this.#bot.sendMessage( - msg.chat.id, + chatId, '🏷 Please choose a category button.', { reply_markup: categoryKeyboard(draft.categories, draft.page) @@ -635,27 +1518,122 @@ export class XpenserTelegramBot { } if (draft.step === 'note-choice') { - await this.askForDescription(msg.chat.id); + await this.askForDescription(chatId); return; } if (draft.step === 'note-text') { - if (text.length > 500) { + const error = noteLengthError(text); + if (error) { + await this.#bot.sendMessage(chatId, `📝 ${error}`); + return; + } + await this.saveTransaction(chatId, user, key, draft, text); + } + } + + async handleScanText( + chatId: number, + key: string, + session: ScanDraftSession, + text: string + ): Promise { + const draft = scanDraftAt(session); + const values = scanValuesAt(session); + if (!draft || !values) { + this.#sessions.delete(key); + await this.#bot.sendMessage(chatId, 'Scan session expired.', { + reply_markup: quickAddReplyKeyboard() + }); + return; + } + + if (session.step === 'edit-amount') { + const amount = parseAmount(text); + if (!amount) { await this.#bot.sendMessage( - msg.chat.id, - '📝 Description is too long. Send up to 500 characters.' + chatId, + '💸 Enter a positive amount, for example 12.50.' ); return; } - await this.saveTransaction(msg.chat.id, user, key, draft, text); + const next: ScanDraftSession = { + ...updateScanValues(session, draft.id, { ...values, amount }), + step: 'review' + }; + this.#sessions.set(key, next); + await this.sendScanReviewPrompt(chatId, next); + return; } + + if (session.step === 'edit-date') { + const occurredAt = parseTelegramDateTime(text, session.me.timezone); + if (!occurredAt) { + await this.#bot.sendMessage( + chatId, + '📅 Send date and time as YYYY-MM-DD HH:mm, for example 2026-06-06 14:30.' + ); + return; + } + const next: ScanDraftSession = { + ...updateScanValues(session, draft.id, { + ...values, + occurredAt + }), + step: 'review' + }; + this.#sessions.set(key, next); + await this.sendScanReviewPrompt(chatId, next); + return; + } + + if (session.step === 'edit-note') { + const note = text === '-' ? '' : text; + const error = noteLengthError(note); + if (error) { + await this.#bot.sendMessage(chatId, `📝 ${error}`); + return; + } + const next: ScanDraftSession = { + ...updateScanValues(session, draft.id, { ...values, note }), + step: 'review' + }; + this.#sessions.set(key, next); + await this.sendScanReviewPrompt(chatId, next); + return; + } + + if (session.step === 'edit-vendor-search') { + const matches = filteredVendors(session.vendors, text); + const next: ScanDraftSession = { + ...session, + step: 'edit-vendor', + vendorPage: 0, + vendorQuery: text + }; + this.#sessions.set(key, next); + await this.#bot.sendMessage( + chatId, + matches.length === 0 + ? 'No matching vendors. Choose No vendor or search again.' + : '🏬 Choose a vendor.', + { + reply_markup: vendorKeyboard(matches, 0, { + includeSearch: true + }) + } + ); + return; + } + + await this.sendScanReviewPrompt(chatId, session); } async setCurrency( chatId: number, key: string, user: TelegramUserBody, - draft: Extract, + draft: Extract, value: string ): Promise { const currency = value.trim().toUpperCase(); @@ -690,16 +1668,6 @@ export class XpenserTelegramBot { occurredAt: new Date() } }); - const next: Draft = { - step: 'category', - me: draft.me, - categories: draft.categories, - currencies: draft.currencies, - amount: draft.amount, - currency, - page: 0 - }; - this.#sessions.set(key, next); await this.#bot.sendMessage( chatId, `💱 ${formatTelegramAmount( @@ -710,7 +1678,40 @@ export class XpenserTelegramBot { conversion.defaultCurrency )} in your primary currency.` ); - await this.sendCategoryPrompt(chatId, next); + + if (draft.vendors.length === 0) { + await this.setManualVendor( + chatId, + key, + { + kind: 'manual', + step: 'vendor', + me: draft.me, + categories: draft.categories, + currencies: draft.currencies, + vendors: draft.vendors, + amount: draft.amount, + currency, + page: 0 + }, + null + ); + return; + } + + const next: ManualDraft = { + kind: 'manual', + step: 'vendor', + me: draft.me, + categories: draft.categories, + currencies: draft.currencies, + vendors: draft.vendors, + amount: draft.amount, + currency, + page: 0 + }; + this.#sessions.set(key, next); + await this.sendVendorPrompt(chatId, next); } catch (err) { await this.#bot.sendMessage( chatId, @@ -723,11 +1724,39 @@ export class XpenserTelegramBot { } } + async setManualVendor( + chatId: number, + key: string, + draft: Extract, + vendorId: number | null + ): Promise { + const vendor = vendorById(draft.vendors, vendorId); + const next: ManualDraft = { + kind: 'manual', + step: 'category', + me: draft.me, + categories: categoriesWithPreferredFirst( + draft.categories, + vendor?.suggestedCategoryId + ), + currencies: draft.currencies, + amount: draft.amount, + currency: draft.currency, + vendorId, + page: 0 + }; + this.#sessions.set(key, next); + await this.sendCategoryPrompt(chatId, next); + } + async saveTransaction( chatId: number, user: TelegramUserBody, key: string, - draft: Extract, + draft: Extract< + ManualDraft, + { readonly step: 'note-choice' | 'note-text' } + >, note?: string ): Promise { try { @@ -735,6 +1764,7 @@ export class XpenserTelegramBot { const transaction = await client.transactions.create({ body: { categoryId: draft.category.id, + vendorId: draft.vendorId, amount: draft.amount, currency: draft.currency, occurredAt: new Date(), @@ -744,7 +1774,7 @@ export class XpenserTelegramBot { this.#sessions.delete(key); await this.#bot.sendMessage( chatId, - `✅ Saved ${transaction.type}: ${transaction.categoryDisplayName}, ${formatTelegramAmount(transaction.amount, transaction.currency)} (${formatTelegramAmount(transaction.defaultCurrencyAmount, transaction.defaultCurrency)}).`, + `✅ Saved ${savedTransactionSummary(transaction)}`, { reply_markup: quickAddReplyKeyboard() } @@ -790,13 +1820,322 @@ export class XpenserTelegramBot { async sendCategoryPrompt( chatId: number, - draft: Extract + draft: Extract ): Promise { await this.#bot.sendMessage(chatId, '🏷 Choose a category.', { reply_markup: categoryKeyboard(draft.categories, draft.page) }); } + async sendVendorPrompt( + chatId: number, + draft: Extract + ): Promise { + const vendors = filteredVendors(draft.vendors, draft.query); + const prefix = draft.query ? ` matching "${draft.query}"` : ''; + await this.#bot.sendMessage(chatId, `🏬 Choose a vendor${prefix}.`, { + reply_markup: vendorKeyboard(vendors, draft.page, { + includeSearch: true + }) + }); + } + + async sendScanVendorPrompt( + chatId: number, + session: ScanDraftSession + ): Promise { + const vendors = filteredVendors(session.vendors, session.vendorQuery); + const prefix = session.vendorQuery + ? ` matching "${session.vendorQuery}"` + : ''; + await this.#bot.sendMessage(chatId, `🏬 Choose a vendor${prefix}.`, { + reply_markup: vendorKeyboard(vendors, session.vendorPage, { + includeSearch: true + }) + }); + } + + scanReviewText(session: ScanDraftSession): string { + const draft = scanDraftAt(session); + const values = scanValuesAt(session); + if (!draft || !values) { + return 'Scan session expired.'; + } + + const category = categoryById(session.categories, values.categoryId); + const vendor = vendorById(session.vendors, values.vendorId); + const suggestedCategory = draft.suggestedCategory + ? `${draft.suggestedCategory.name} (${draft.suggestedCategory.type})` + : undefined; + const suggestedVendor = + !vendor && draft.suggestedVendorName + ? draft.suggestedVendorName + : undefined; + const lines = [ + `Transaction ${session.index + 1} of ${session.scan.drafts.length}`, + `Source: ${session.scan.documentKind.replace('_', ' ')}`, + `Status: ${session.decisions[draft.id] ?? 'pending'}`, + '', + `Amount: ${ + values.amount + ? formatTelegramAmount(values.amount, values.currency) + : 'Not set' + }`, + `Category: ${category?.displayName ?? 'Not set'}`, + `Vendor: ${vendor ? vendorLabel(vendor) : 'No vendor'}`, + `Date: ${formatTelegramDate(values.occurredAt, session.me.timezone)}`, + `Note: ${values.note || 'None'}`, + '', + `Evidence: ${draft.evidence || 'No supporting text was returned.'}`, + `Confidence: ${draft.confidence.overall}` + ]; + + if (suggestedCategory) { + lines.push(`Suggested category: ${suggestedCategory}`); + } + if (suggestedVendor) { + lines.push(`Suggested vendor: ${suggestedVendor}`); + } + if (draft.possibleDuplicateTransactionIds.length > 0) { + lines.push( + `Possible duplicate: ${draft.possibleDuplicateTransactionIds.join(', ')}` + ); + } + if (session.scan.warnings.length > 0) { + lines.push( + '', + ...session.scan.warnings.map(warning => `Warning: ${warning}`) + ); + } + + return lines.join('\n'); + } + + async sendScanReviewPrompt( + chatId: number, + session: ScanDraftSession + ): Promise { + await this.#bot.sendMessage(chatId, this.scanReviewText(session), { + reply_markup: scanReviewKeyboard(session) + }); + } + + async confirmScanDraft( + chatId: number, + user: TelegramUserBody, + key: string, + session: ScanDraftSession, + draft: TransactionScanDraft, + values: ScanDraftValues + ): Promise { + if (!values.amount) { + await this.#bot.sendMessage( + chatId, + '💸 Set an amount before confirming.' + ); + return; + } + if (!values.categoryId) { + await this.#bot.sendMessage( + chatId, + '🏷 Set a category before confirming.' + ); + return; + } + + try { + const client = await this.userClient(user); + const transaction = await client.transactions.create({ + body: { + amount: values.amount, + categoryId: values.categoryId, + currency: values.currency, + occurredAt: values.occurredAt, + vendorId: values.vendorId, + note: values.note.trim() || undefined + } + }); + await client.transactionScans.decide({ + params: { + scanId: session.scan.scanId, + itemId: draft.id + }, + body: { + decision: 'confirmed', + transactionId: transaction.id, + correctedTransaction: { + amount: values.amount, + categoryId: values.categoryId, + currency: values.currency, + occurredAt: values.occurredAt, + vendorId: values.vendorId, + note: values.note.trim() || null + }, + attachment: session.attachmentSubmitted + ? undefined + : session.attachment + } + }); + + const decided: ScanDraftSession = { + ...session, + step: 'review', + decisions: { + ...session.decisions, + [draft.id]: 'confirmed' + }, + attachmentSubmitted: true + }; + await this.#bot.sendMessage( + chatId, + `✅ Saved ${savedTransactionSummary(transaction)}` + ); + await this.advanceScanAfterDecision(chatId, key, decided); + } catch (err) { + await this.#bot.sendMessage( + chatId, + apiErrorMessage(err) ?? + 'Could not save this scanned transaction.' + ); + } + } + + async discardScanDraft( + chatId: number, + user: TelegramUserBody, + key: string, + session: ScanDraftSession, + draft: TransactionScanDraft + ): Promise { + try { + const client = await this.userClient(user); + await client.transactionScans.decide({ + params: { + scanId: session.scan.scanId, + itemId: draft.id + }, + body: { decision: 'discarded' } + }); + await this.advanceScanAfterDecision(chatId, key, { + ...session, + step: 'review', + decisions: { + ...session.decisions, + [draft.id]: 'discarded' + } + }); + } catch (err) { + await this.#bot.sendMessage( + chatId, + apiErrorMessage(err) ?? + 'Could not discard this scanned transaction.' + ); + } + } + + async advanceScanAfterDecision( + chatId: number, + key: string, + session: ScanDraftSession + ): Promise { + const nextIndex = nextPendingScanIndex(session); + if (nextIndex === undefined) { + const confirmed = Object.values(session.decisions).filter( + value => value === 'confirmed' + ).length; + const discarded = Object.values(session.decisions).filter( + value => value === 'discarded' + ).length; + this.#sessions.delete(key); + await this.#bot.sendMessage( + chatId, + `Scan reviewed. Confirmed ${confirmed} and discarded ${discarded}.`, + { + reply_markup: quickAddReplyKeyboard() + } + ); + return; + } + + const next: ScanDraftSession = { + ...session, + index: nextIndex, + categoryPage: 0, + vendorPage: 0, + vendorQuery: undefined + }; + this.#sessions.set(key, next); + await this.sendScanReviewPrompt(chatId, next); + } + + async downloadTelegramFile(media: TelegramScanMedia): Promise { + const stream = this.#bot.getFileStream(media.fileId); + if (!(stream instanceof Readable)) { + throw new Error('Could not download the image. Try again.'); + } + + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of stream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buffer.length; + if (size > TransactionScanLimits.maxImageBytes) { + throw new Error('Image must be 10 MB or smaller.'); + } + chunks.push(buffer); + } + + if (size === 0) { + throw new Error('Choose an image to scan.'); + } + + return Buffer.concat(chunks, size); + } + + async waitForScanJob( + client: XpenserClient, + job: TransactionScanJobResponse, + onProgress: (event: TransactionScanProgressEvent) => Promise + ): Promise { + const subscription = client.transactionScans.progress({ + query: { jobId: job.jobId, token: job.token }, + reconnect: { maxRetries: 3, backoffLimit: 5_000 } + }); + + try { + for await (const event of subscription) { + await onProgress(event); + if (event.stage === 'failed') { + throw new Error( + event.error ?? 'Could not scan the image. Try again.' + ); + } + if (event.stage === 'complete' && event.scan) { + return event.scan; + } + } + } finally { + subscription.close(); + } + + throw new Error('Could not connect to scan progress. Try again.'); + } + + async updateProgressMessage( + chatId: number, + messageId: number, + text: string + ): Promise { + try { + await this.#bot.editMessageText(text, { + chat_id: chatId, + message_id: messageId + }); + } catch { + await this.#bot.sendMessage(chatId, text); + } + } + async userClient(user: TelegramUserBody): Promise { const response = await this.#serviceClient.telegram.token({ body: { telegramUser: user } diff --git a/apps/telegram-bot/src/flow.test.ts b/apps/telegram-bot/src/flow.test.ts index a7769b7..dee5f62 100644 --- a/apps/telegram-bot/src/flow.test.ts +++ b/apps/telegram-bot/src/flow.test.ts @@ -2,18 +2,26 @@ import type { Category, Currency, Transaction, - UserPreference + UserPreference, + Vendor } from '@xpenser/contracts'; import { describe, expect, it } from 'vitest'; import { addButtonText, categoriesByRecentUse, + categoriesWithPreferredFirst, currencyKeyboard, + filteredVendors, isAddButtonText, + isAllowedScanImageMimeType, parseAmount, parseStartToken, + parseTelegramDateTime, preferredCurrencies, - quickAddReplyKeyboard + quickAddReplyKeyboard, + scanImageSizeError, + vendorKeyboard, + vendorSelectCallbackPrefix } from './flow.js'; describe('telegram bot flow helpers', () => { @@ -78,6 +86,78 @@ describe('telegram bot flow helpers', () => { ]); }); + it('parses local Telegram date-time edits in the user timezone', () => { + expect( + parseTelegramDateTime( + '2026-05-10 09:30', + 'America/New_York' + )?.toISOString() + ).toBe('2026-05-10T13:30:00.000Z'); + expect( + parseTelegramDateTime( + '2026-05-10T09:30', + 'America/New_York' + )?.toISOString() + ).toBe('2026-05-10T13:30:00.000Z'); + expect(parseTelegramDateTime('05/10/2026', 'UTC')).toBeUndefined(); + }); + + it('validates supported scan image types and sizes', () => { + expect(isAllowedScanImageMimeType('image/jpeg')).toBe(true); + expect(isAllowedScanImageMimeType('image/png')).toBe(true); + expect(isAllowedScanImageMimeType('image/webp')).toBe(true); + expect(isAllowedScanImageMimeType('application/pdf')).toBe(false); + expect(scanImageSizeError(10 * 1024 * 1024)).toBeUndefined(); + expect(scanImageSizeError(10 * 1024 * 1024 + 1)).toBe( + 'Image must be 10 MB or smaller.' + ); + }); + + it('filters and renders existing vendor choices', () => { + const vendors = [ + { + id: 1, + name: 'Acme Groceries', + displayName: 'Acme Groceries' + }, + { + id: 2, + name: 'Coffee Bar', + displayName: 'Coffee Bar', + domain: 'coffee.example' + } + ] as Vendor[]; + + expect( + filteredVendors(vendors, 'coffee').map(vendor => vendor.id) + ).toEqual([2]); + expect(vendorKeyboard(vendors, 0).inline_keyboard).toContainEqual([ + { + text: 'Acme Groceries', + callback_data: `${vendorSelectCallbackPrefix}1` + } + ]); + }); + + it('moves a vendor suggested category to the front', () => { + const categories = [ + { id: 1, displayName: 'Food', type: 'expense' }, + { id: 2, displayName: 'Coffee', type: 'expense' }, + { id: 3, displayName: 'Travel', type: 'expense' } + ] as Category[]; + + expect( + categoriesWithPreferredFirst(categories, 2).map( + category => category.id + ) + ).toEqual([2, 1, 3]); + expect( + categoriesWithPreferredFirst(categories, 999).map( + category => category.id + ) + ).toEqual([1, 2, 3]); + }); + it('sorts recently used categories first', () => { const categories = [ { diff --git a/apps/telegram-bot/src/flow.ts b/apps/telegram-bot/src/flow.ts index 7120823..be0d06c 100644 --- a/apps/telegram-bot/src/flow.ts +++ b/apps/telegram-bot/src/flow.ts @@ -2,14 +2,38 @@ import type { Category, Currency, Transaction, - UserPreference + TransactionScanDraft, + UserPreference, + Vendor } from '@xpenser/contracts'; +import { FieldLimits, TransactionScanLimits } from '@xpenser/contracts'; +import { localDateTimeInputToDate } from '@xpenser/timezone'; export const cancelCallback = 'cancel'; export const noteSkipCallback = 'note:skip'; export const noteAddCallback = 'note:add'; +export const vendorNoneCallback = 'vendor:none'; +export const vendorSearchCallback = 'vendor:search'; +export const vendorPageCallbackPrefix = 'vendorpage:'; +export const vendorSelectCallbackPrefix = 'vendor:select:'; +export const scanConfirmCallback = 'scan:confirm'; +export const scanDiscardCallback = 'scan:discard'; +export const scanPreviousCallback = 'scan:previous'; +export const scanNextCallback = 'scan:next'; +export const scanEditAmountCallback = 'scan:edit:amount'; +export const scanEditCategoryCallback = 'scan:edit:category'; +export const scanEditCurrencyCallback = 'scan:edit:currency'; +export const scanEditDateCallback = 'scan:edit:date'; +export const scanEditNoteCallback = 'scan:edit:note'; +export const scanEditVendorCallback = 'scan:edit:vendor'; export const addCommand = '/add'; export const addButtonText = 'Add'; +export const allowedScanImageMimeTypes = [ + 'image/jpeg', + 'image/png', + 'image/webp' +] as const; +export const vendorPageSize = 6; type InlineKeyboardButton = { readonly text: string; @@ -50,6 +74,46 @@ export function parseAmount(text: string | undefined): number | undefined { return Number.isFinite(amount) && amount > 0 ? amount : undefined; } +export function parseTelegramDateTime( + text: string | undefined, + timeZone: string +): Date | undefined { + const normalized = (text ?? '').trim().replace(/\s+/, 'T'); + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/.test(normalized)) { + return undefined; + } + return localDateTimeInputToDate(normalized, timeZone); +} + +export function noteLengthError(text: string): string | undefined { + return text.length > FieldLimits.transactionNote + ? `Description is too long. Send up to ${FieldLimits.transactionNote} characters.` + : undefined; +} + +export function isAllowedScanImageMimeType( + value: string | undefined +): value is (typeof allowedScanImageMimeTypes)[number] { + return allowedScanImageMimeTypes.includes( + value as (typeof allowedScanImageMimeTypes)[number] + ); +} + +export function scanImageSizeError( + fileSize: number | undefined +): string | undefined { + if (fileSize === undefined) { + return undefined; + } + if (!Number.isSafeInteger(fileSize) || fileSize <= 0) { + return 'Choose an image to scan.'; + } + if (fileSize > TransactionScanLimits.maxImageBytes) { + return 'Image must be 10 MB or smaller.'; + } + return undefined; +} + export function preferredCurrencies( me: UserPreference, currencies: readonly Currency[] @@ -77,6 +141,99 @@ export function currencyKeyboard( return { inline_keyboard: rows }; } +export function vendorLabel(vendor: Vendor): string { + return vendor.displayName || vendor.resolvedName || vendor.name; +} + +export function vendorMatches(vendor: Vendor, query: string): boolean { + const search = query.trim().toLowerCase(); + if (!search) { + return true; + } + + return [ + vendor.name, + vendor.displayName, + vendor.resolvedName, + vendor.domain, + vendor.description + ].some(value => value?.toLowerCase().includes(search)); +} + +export function filteredVendors( + vendors: readonly Vendor[], + query: string | undefined +): Vendor[] { + return vendors.filter(vendor => vendorMatches(vendor, query ?? '')); +} + +export function vendorKeyboard( + vendors: readonly Vendor[], + page: number, + options: { readonly includeSearch?: boolean } = {} +): InlineKeyboardMarkup { + const pageCount = Math.max(1, Math.ceil(vendors.length / vendorPageSize)); + const safePage = Math.min(Math.max(page, 0), pageCount - 1); + const start = safePage * vendorPageSize; + const rows = vendors.slice(start, start + vendorPageSize).map(vendor => [ + { + text: vendorLabel(vendor), + callback_data: `${vendorSelectCallbackPrefix}${vendor.id}` + } + ]); + + const navigation = []; + if (safePage > 0) { + navigation.push({ + text: 'Previous', + callback_data: `${vendorPageCallbackPrefix}${safePage - 1}` + }); + } + if (safePage < pageCount - 1) { + navigation.push({ + text: 'Next', + callback_data: `${vendorPageCallbackPrefix}${safePage + 1}` + }); + } + if (navigation.length > 0) { + rows.push(navigation); + } + if (options.includeSearch) { + rows.push([ + { text: 'Search vendors', callback_data: vendorSearchCallback } + ]); + } + rows.push([{ text: 'No vendor', callback_data: vendorNoneCallback }]); + rows.push([{ text: 'Cancel', callback_data: cancelCallback }]); + + return { inline_keyboard: rows }; +} + +export function categoriesWithPreferredFirst( + categories: readonly Category[], + categoryId: number | undefined | null +): Category[] { + if (!categoryId) { + return [...categories]; + } + const selected = categories.find(category => category.id === categoryId); + if (!selected) { + return [...categories]; + } + return [ + selected, + ...categories.filter(category => category.id !== selected.id) + ]; +} + +export function draftCategoryType( + draft: TransactionScanDraft, + categories: readonly Category[] +): Category['type'] { + const category = categories.find(item => item.id === draft.categoryId); + return category?.type ?? draft.transactionType; +} + export function categoriesByRecentUse( categories: readonly Category[], transactions: readonly Transaction[] diff --git a/apps/telegram-bot/src/tracing.test.ts b/apps/telegram-bot/src/tracing.test.ts index 73c585c..4a3e7fc 100644 --- a/apps/telegram-bot/src/tracing.test.ts +++ b/apps/telegram-bot/src/tracing.test.ts @@ -18,6 +18,12 @@ describe('telegram tracing helpers', () => { expect(telegramCallbackAction('cat:123')).toBe('category_select'); expect(telegramCallbackAction('catpage:2')).toBe('category_page'); expect(telegramCallbackAction('cur:USD')).toBe('currency_select'); + expect(telegramCallbackAction('vendor:select:123')).toBe( + 'vendor_select' + ); + expect(telegramCallbackAction('vendorpage:2')).toBe('vendor_page'); + expect(telegramCallbackAction('scan:edit:amount')).toBe('scan_edit'); + expect(telegramCallbackAction('scan:confirm')).toBe('scan_confirm'); expect(telegramCallbackAction('note:add')).toBe('note_add'); expect(telegramCallbackAction('unknown:secret')).toBe('unknown'); }); diff --git a/apps/telegram-bot/src/tracing.ts b/apps/telegram-bot/src/tracing.ts index da46708..5b23b67 100644 --- a/apps/telegram-bot/src/tracing.ts +++ b/apps/telegram-bot/src/tracing.ts @@ -49,12 +49,39 @@ export function telegramCallbackAction(data: string | undefined): string { if (data.startsWith('cur:')) { return 'currency_select'; } + if (data === 'vendor:none') { + return 'vendor_none'; + } + if (data === 'vendor:search') { + return 'vendor_search'; + } + if (data.startsWith('vendorpage:')) { + return 'vendor_page'; + } + if (data.startsWith('vendor:select:')) { + return 'vendor_select'; + } if (data === 'note:skip') { return 'note_skip'; } if (data === 'note:add') { return 'note_add'; } + if (data.startsWith('scan:edit:')) { + return 'scan_edit'; + } + if (data === 'scan:confirm') { + return 'scan_confirm'; + } + if (data === 'scan:discard') { + return 'scan_discard'; + } + if (data === 'scan:previous') { + return 'scan_previous'; + } + if (data === 'scan:next') { + return 'scan_next'; + } return 'unknown'; } diff --git a/package-lock.json b/package-lock.json index c623a28..b71bdf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -72,6 +72,7 @@ "@opentelemetry/instrumentation-undici": "0.25.0", "@xpenser/client": "0.1.0", "@xpenser/contracts": "0.1.0", + "@xpenser/timezone": "0.1.0", "node-telegram-bot-api": "^0.66.0" }, "devDependencies": {