diff --git a/docs/tasks/plan.md b/docs/tasks/plan.md index e91d248..27611ea 100644 --- a/docs/tasks/plan.md +++ b/docs/tasks/plan.md @@ -13,7 +13,7 @@ Replace Telegraf with grammY and @grammyjs/conversations to fix lifecycle/state | 2 | Migrate query commands (stateless) | done (PR #3) | 1 | [002](002-migrate-query-commands.md) | | 3 | Rewrite add-bill flow as grammY conversation | done (PR #4) | 1 | [003](003-rewrite-add-bill-conversation.md) | | 4 | Rewrite categorization flow as grammY conversation | done (PR #5) | 1 | [004](004-rewrite-categorization-conversation.md) | -| 5 | Migrate TelegramAdapter and remove Telegraf | pending | 2, 3, 4 | [005](005-migrate-telegram-adapter.md) | +| 5 | Migrate TelegramAdapter and remove Telegraf | reviewed | 2, 3, 4 | [005](005-migrate-telegram-adapter.md) | | 6 | Wire event-driven categorization entry | pending | 4, 5 | [006](006-wire-event-driven-categorization.md) | ## Dependency Graph diff --git a/package-lock.json b/package-lock.json index 9924bc3..7ab1de1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,7 @@ "grammy": "^1.42.0", "html-to-text": "^9.0.5", "node-cron": "^3.0.2", - "openai": "^4.6.0", - "telegraf": "^4.12.2" + "openai": "^4.6.0" }, "devDependencies": { "@types/html-to-text": "^9.0.4", @@ -984,12 +983,6 @@ "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@telegraf/types": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@telegraf/types/-/types-7.1.0.tgz", - "integrity": "sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==", - "license": "MIT" - }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -1542,34 +1535,12 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer-alloc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", - "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", - "license": "MIT", - "dependencies": { - "buffer-alloc-unsafe": "^1.1.0", - "buffer-fill": "^1.0.0" - } - }, - "node_modules/buffer-alloc-unsafe": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", - "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", - "license": "MIT" - }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/buffer-fill": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", - "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", - "license": "MIT" - }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3811,15 +3782,6 @@ "node": "*" } }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4058,15 +4020,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-timeout": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-4.1.0.tgz", - "integrity": "sha512-+/wmHtzJuWii1sXn3HCuH/FTwGhrp4tmJTxSKJbfS+vkipci6osxXM5mY0jUiRzWKMTgUT8l7HFbeSwZAynqHw==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -4350,24 +4303,6 @@ ], "license": "MIT" }, - "node_modules/safe-compare": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/safe-compare/-/safe-compare-1.1.4.tgz", - "integrity": "sha512-b9wZ986HHCo/HbKrRpBJb2kqXMK9CEWIE1egeEvZsYn69ay3kdfl9nG3RyOcR+jInTDf7a86WQ1d4VJX7goSSQ==", - "license": "MIT", - "dependencies": { - "buffer-alloc": "^1.2.0" - } - }, - "node_modules/sandwich-stream": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/sandwich-stream/-/sandwich-stream-2.0.2.tgz", - "integrity": "sha512-jLYV0DORrzY3xaz/S9ydJL6Iz7essZeAfnAavsJ+zsJGZ1MOnsS52yRjU3uF3pJa/lla7+wisp//fxOwOH8SKQ==", - "license": "Apache-2.0", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/selderee": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz", @@ -4650,28 +4585,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/telegraf": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/telegraf/-/telegraf-4.16.3.tgz", - "integrity": "sha512-yjEu2NwkHlXu0OARWoNhJlIjX09dRktiMQFsM678BAH/PEPVwctzL67+tvXqLCRQQvm3SDtki2saGO9hLlz68w==", - "license": "MIT", - "dependencies": { - "@telegraf/types": "^7.1.0", - "abort-controller": "^3.0.0", - "debug": "^4.3.4", - "mri": "^1.2.0", - "node-fetch": "^2.7.0", - "p-timeout": "^4.1.0", - "safe-compare": "^1.1.4", - "sandwich-stream": "^2.0.2" - }, - "bin": { - "telegraf": "lib/cli.mjs" - }, - "engines": { - "node": "^12.20.0 || >=14.13.1" - } - }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", diff --git a/package.json b/package.json index 345309f..0794fc0 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,7 @@ "grammy": "^1.42.0", "html-to-text": "^9.0.5", "node-cron": "^3.0.2", - "openai": "^4.6.0", - "telegraf": "^4.12.2" + "openai": "^4.6.0" }, "devDependencies": { "@types/html-to-text": "^9.0.4", diff --git a/src/infrastructure/telegram/command-handlers.ts b/src/infrastructure/telegram/command-handlers.ts deleted file mode 100644 index b40568a..0000000 --- a/src/infrastructure/telegram/command-handlers.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { Telegraf, Context } from "telegraf"; -import { ILogger, container, Logger } from "../utils"; -import { TG_ACCOUNTS } from "../utils/telegram"; -import { PendingCategorization, TextMessage } from "./types"; -import { CategorizationCommandHandler } from "./commands/categorization-command-handler"; -import { QueryCommandHandler } from "./commands/query-command-handler"; -import { AddCommandHandler } from "./commands/add-command-handler"; -import { CustomQueryCommandHandler } from "./commands/custom-query-command-handler"; - -// 用户状态枚举 -export enum UserState { - IDLE = "IDLE", // 空闲状态 - ADDING_BILL = "ADDING_BILL", // 正在添加账单 - CATEGORIZING = "CATEGORIZING", // 正在分类 -} - -export class CommandHandlers { - private logger: ILogger; - private bot: Telegraf; - private categorizationHandler: CategorizationCommandHandler; - private queryHandler: QueryCommandHandler; - private addHandler: AddCommandHandler; - private customQueryHandler: CustomQueryCommandHandler; - - // 用户状态管理 - private userStates: Map = new Map(); - // 交易数据管理 - private transactionData: Map = new Map(); - - constructor(bot: Telegraf) { - this.logger = container.getByClass(Logger); - this.bot = bot; - - // 先创建 CategorizationCommandHandler,因为它需要 CommandHandlers 实例 - this.categorizationHandler = new CategorizationCommandHandler(bot, this); - - this.queryHandler = new QueryCommandHandler(bot as any); - this.addHandler = new AddCommandHandler(bot, this); - this.customQueryHandler = new CustomQueryCommandHandler(bot as any); - - this.setupMessageHandler(); - this.setupCommandHandlers(); - } - - private setupMessageHandler(): void { - this.bot.on("message", async (ctx, next) => { - // Check if message is from whitelisted user - const username = ctx.from?.username; - if (!username || !TG_ACCOUNTS.includes(username)) { - this.logger.debug( - `Ignoring message from non-whitelisted user: ${username}` - ); - return; - } - - // 如果不是文本消息,直接传递给下一个处理器 - if (!this.isTextMessage(ctx)) { - return next(); - } - - // 如果是命令消息,直接传递给下一个处理器 - if (this.isCommandMessage(ctx)) { - return next(); - } - - const chatId = ctx.chat?.id.toString(); - if (!chatId) { - return next(); - } - - try { - // 根据用户当前状态决定由哪个处理器处理消息 - const userState = this.getUserState(chatId); - - switch (userState) { - case UserState.ADDING_BILL: - // 用户正在添加账单,由 AddCommandHandler 处理 - await this.addHandler.handleMessage(ctx); - break; - - case UserState.CATEGORIZING: - // 用户正在分类,由 CategorizationCommandHandler 处理 - await this.categorizationHandler.handleMessage(ctx); - break; - - case UserState.IDLE: - default: - const handledByCustomQuery = await this.customQueryHandler.handle(ctx as any); - if (handledByCustomQuery) { - break; - } - - // 用户处于空闲状态,尝试让 AddCommandHandler 处理 - // 如果 AddCommandHandler 没有处理,则尝试让 CategorizationCommandHandler 处理 - const handledByAdd = await this.addHandler.handleMessage(ctx); - if (!handledByAdd) { - await this.categorizationHandler.handleMessage(ctx); - } - break; - } - } catch (error) { - this.logger.error("Error in message handler:", error); - await ctx.reply( - "Sorry, I encountered an error while processing your message." - ); - } - }); - } - - private isTextMessage(ctx: Context): boolean { - return ctx.message !== undefined && "text" in ctx.message; - } - - private isCommandMessage(ctx: Context): boolean { - return ( - ctx.message !== undefined && - "text" in ctx.message && - ctx.message.text.startsWith("/") - ); - } - - // 获取用户当前状态 - public getUserState(chatId: string): UserState { - return this.userStates.get(chatId) || UserState.IDLE; - } - - // 设置用户状态 - public setUserState(chatId: string, state: UserState): void { - this.userStates.set(chatId, state); - } - - // 重置用户状态为空闲 - public resetUserState(chatId: string): void { - this.userStates.set(chatId, UserState.IDLE); - } - - // 设置交易数据 - public setTransactionData(chatId: string, data: any): void { - this.transactionData.set(chatId, data); - } - - // 获取交易数据 - public getTransactionData(chatId: string): any { - return this.transactionData.get(chatId); - } - - // 清除交易数据 - public clearTransactionData(chatId: string): void { - this.transactionData.delete(chatId); - } - - private setupCommandHandlers(): void { - // Set up start command - this.bot.command("start", async (ctx) => { - try { - await this.handleStart(ctx); - } catch (error) { - this.logger.error("Error handling start command:", error); - await ctx.reply( - "Sorry, I encountered an error while processing your command." - ); - } - }); - - // Set up add command - this.bot.command("add", async (ctx) => { - try { - const chatId = ctx.chat?.id.toString(); - if (chatId) { - this.setUserState(chatId, UserState.ADDING_BILL); - } - await this.addHandler.handle(ctx); - } catch (error) { - this.logger.error("Error handling add command:", error); - await ctx.reply( - "Sorry, I encountered an error while processing your command." - ); - } - }); - - // Set up query command - this.bot.command("query", async (ctx) => { - try { - await this.queryHandler.handle(ctx as any); - } catch (error) { - this.logger.error("Error handling query command:", error); - await ctx.reply( - "Sorry, I encountered an error while processing your command." - ); - } - }); - - // Set up cancel command - this.bot.command("cancel", async (ctx) => { - try { - const chatId = ctx.chat?.id.toString(); - if (chatId) { - this.resetUserState(chatId); - } - await ctx.reply("Operation cancelled. You are now in idle state."); - } catch (error) { - this.logger.error("Error handling cancel command:", error); - await ctx.reply( - "Sorry, I encountered an error while processing your command." - ); - } - }); - } - - async handleStart(ctx: Context): Promise { - await ctx.reply( - "👋 Welcome to Bean Talk! I'm here to help you manage your finances.\n\nI can help you:\n- Categorize merchants\n- Query your transactions\n- Add new bills\n- And more coming soon!" - ); - } - - // 发送通知 - async sendNotification( - chatId: string, - message: string, - merchantId?: string, - categorizationData?: PendingCategorization - ): Promise { - if (merchantId && categorizationData) { - // 如果需要分类,使用 categorizationHandler - this.setUserState(chatId, UserState.CATEGORIZING); - await this.categorizationHandler.sendNotification( - chatId, - message, - merchantId, - categorizationData - ); - } else { - // 普通通知直接发送 - await this.bot.telegram.sendMessage(chatId, message, { - parse_mode: "HTML", - }); - } - } -} diff --git a/src/infrastructure/telegram/commands/__tests__/categorization-command-handler.test.ts b/src/infrastructure/telegram/commands/__tests__/categorization-command-handler.test.ts deleted file mode 100644 index 9819a8e..0000000 --- a/src/infrastructure/telegram/commands/__tests__/categorization-command-handler.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -import { Telegraf } from 'telegraf'; -import { CategorizationCommandHandler } from '../categorization-command-handler'; -import { container } from '../../../utils'; -import { NLPService } from '../../../../domain/services/nlp.service'; -import { ApplicationEventEmitter } from '../../../events/event-emitter'; -import { Logger } from '../../../utils'; -import { MESSAGES } from '../categorization-constants'; -import { EventTypes } from '../../../events/event-types'; -import { CommandHandlers } from '../../command-handlers'; - -// Mock dependencies -jest.mock('telegraf'); -jest.mock('../../../utils', () => ({ - container: { - getByClass: jest.fn() - } -})); - -describe('CategorizationCommandHandler', () => { - let handler: CategorizationCommandHandler; - let mockBot: jest.Mocked; - let mockNlpService: jest.Mocked; - let mockEventEmitter: jest.Mocked; - let mockLogger: jest.Mocked; - - beforeEach(() => { - // Reset mocks - jest.clearAllMocks(); - - // Setup mock implementations - const mockSendMessage = jest.fn(); - mockBot = { - telegram: { - sendMessage: mockSendMessage - }, - action: jest.fn().mockReturnThis(), - on: jest.fn().mockReturnThis() - } as unknown as jest.Mocked; - - mockNlpService = { - categorizeMerchant: jest.fn() - } as unknown as jest.Mocked; - - mockEventEmitter = { - emit: jest.fn() - } as unknown as jest.Mocked; - - mockLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn() - } as unknown as jest.Mocked; - - // Setup container mock - (container.getByClass as jest.Mock).mockImplementation((classType) => { - if (classType === NLPService) return mockNlpService; - if (classType === ApplicationEventEmitter) return mockEventEmitter; - if (classType === Logger) return mockLogger; - return null; - }); - - // Create handler instance with mock command handlers - const mockCommandHandlers = { - setUserState: jest.fn(), - getUserState: jest.fn(), - resetUserState: jest.fn(), - } as unknown as CommandHandlers; - handler = new CategorizationCommandHandler(mockBot, mockCommandHandlers); - }); - - describe('sendNotification', () => { - it('should send a notification with correct keyboard when merchantId is provided', async () => { - // Arrange - const chatId = '123456'; - const message = 'Test message'; - const merchantId = 'merchant123'; - const categorizationData = { - merchantId: 'merchant123', - merchant: 'Test Merchant', - chatId: '123456', - timestamp: new Date().toISOString() - }; - - // Mock bot.telegram.sendMessage - (mockBot.telegram.sendMessage as jest.Mock).mockResolvedValue(undefined); - - // Act - await handler.sendNotification(chatId, message, merchantId, categorizationData); - - // Assert - expect(mockBot.telegram.sendMessage).toHaveBeenCalled(); - expect(handler.getPendingCategorization(merchantId)).toBeDefined(); - }); - - it('should handle errors when sending notification fails', async () => { - // Arrange - const chatId = '123456'; - const message = 'Test message'; - const error = new Error('Send message failed'); - (mockBot.telegram.sendMessage as jest.Mock).mockRejectedValue(error); - - // Act & Assert - await expect(handler.sendNotification(chatId, message)).rejects.toThrow(error); - expect(mockLogger.error).toHaveBeenCalled(); - }); - }); - - describe('handleCategorizeMerchantCallback', () => { - it('should handle valid merchant categorization request', async () => { - // Arrange - const ctx = { - chat: { id: '123456' }, - answerCbQuery: jest.fn(), - reply: jest.fn() - } as any; - - const truncatedId = 'abc123'; - const fullMerchantId = 'merchant123'; - - // Add pending categorization - handler.addTruncatedIdMapping(truncatedId, fullMerchantId); - handler['pendingCategorizations'].set(fullMerchantId, { - merchantId: fullMerchantId, - merchant: 'Test Merchant', - chatId: '123456', - timestamp: new Date().toISOString() - }); - - // Act - await handler.handleCategorizeMerchantCallback(ctx, truncatedId); - - // Assert - expect(ctx.answerCbQuery).toHaveBeenCalled(); - expect(ctx.reply).toHaveBeenCalled(); - }); - }); - - describe('processCategorizationRequest', () => { - it('should process categorization request successfully', async () => { - // Arrange - const ctx = { - reply: jest.fn() - } as any; - - const pendingCategorization = { - merchantId: 'merchant123', - merchant: 'Test Merchant', - chatId: '123456', - timestamp: new Date().toISOString() - }; - - const userInput = 'This is a coffee shop'; - const mockCategories = { - primaryCategory: 'Food & Dining', - alternativeCategory: 'Coffee Shops', - suggestedNewCategory: 'Cafes' - }; - - // Mock NLP service response - (mockNlpService.categorizeMerchant as jest.Mock).mockResolvedValue(mockCategories); - - // Act - await handler['processCategorizationRequest'](ctx, pendingCategorization, userInput); - - // Assert - expect(ctx.reply).toHaveBeenCalledWith(MESSAGES.ANALYZING); - expect(mockNlpService.categorizeMerchant).toHaveBeenCalledWith( - pendingCategorization.merchant, - userInput - ); - expect(ctx.reply).toHaveBeenCalledTimes(2); // Once for ANALYZING, once for result - expect(handler['categorizationMap'].size).toBe(1); - }); - - it('should handle errors during categorization processing', async () => { - // Arrange - const ctx = { - reply: jest.fn() - } as any; - - const pendingCategorization = { - merchantId: 'merchant123', - merchant: 'Test Merchant', - chatId: '123456', - timestamp: new Date().toISOString() - }; - - const userInput = 'This is a coffee shop'; - const error = new Error('NLP service error'); - - // Mock NLP service error - (mockNlpService.categorizeMerchant as jest.Mock).mockRejectedValue(error); - - // Act - await handler['processCategorizationRequest'](ctx, pendingCategorization, userInput); - - // Assert - expect(ctx.reply).toHaveBeenCalledWith(MESSAGES.ANALYZING); - expect(mockLogger.error).toHaveBeenCalledWith('Error processing categorization:', error); - expect(ctx.reply).toHaveBeenCalledWith(MESSAGES.CATEGORIZATION_ERROR); - }); - }); - - describe('handleCategorySelection', () => { - it('should handle category selection successfully', async () => { - // Arrange - const ctx = { - chat: { id: '123456' }, - answerCbQuery: jest.fn(), - editMessageText: jest.fn() - } as any; - - const shortId = 'abc123'; - const categoryType = 'primary'; - const merchantId = 'merchant123'; - const selectedCategory = 'Food & Dining'; - - // Setup categorization data - handler['categorizationMap'].set(shortId, { - merchantId: merchantId, - categories: { - primary: selectedCategory, - alternative: 'Coffee Shops', - suggested: 'Cafes' - } - }); - - // Setup pending categorization - handler['pendingCategorizations'].set(merchantId, { - merchantId: merchantId, - merchant: 'Test Merchant', - chatId: '123456', - timestamp: new Date().toISOString() - }); - - // Act - await handler.handleCategorySelection(ctx, shortId, categoryType); - - // Assert - expect(ctx.answerCbQuery).not.toHaveBeenCalled(); // Should not be called on success - expect(mockEventEmitter.emit).toHaveBeenCalledWith(EventTypes.MERCHANT_CATEGORY_SELECTED, expect.any(Object)); - expect(ctx.editMessageText).toHaveBeenCalledWith( - MESSAGES.CATEGORY_SELECTED('Test Merchant', selectedCategory) - ); - expect(handler['pendingCategorizations'].has(merchantId)).toBe(false); - expect(handler['categorizationMap'].has(shortId)).toBe(false); - }); - - it('should handle missing chat ID', async () => { - // Arrange - const ctx = { - answerCbQuery: jest.fn() - } as any; - - const shortId = 'abc123'; - const categoryType = 'primary'; - - // Act - await handler.handleCategorySelection(ctx, shortId, categoryType); - - // Assert - expect(ctx.answerCbQuery).toHaveBeenCalledWith(MESSAGES.ERROR_CHAT_ID_NOT_FOUND); - }); - - it('should handle missing categorization data', async () => { - // Arrange - const ctx = { - chat: { id: '123456' }, - answerCbQuery: jest.fn() - } as any; - - const shortId = 'abc123'; - const categoryType = 'primary'; - - // Act - await handler.handleCategorySelection(ctx, shortId, categoryType); - - // Assert - expect(ctx.answerCbQuery).toHaveBeenCalledWith(MESSAGES.ERROR_CATEGORIZATION_NOT_FOUND); - }); - }); -}); \ No newline at end of file diff --git a/src/infrastructure/telegram/commands/add-command-handler.ts b/src/infrastructure/telegram/commands/add-command-handler.ts deleted file mode 100644 index 858ad90..0000000 --- a/src/infrastructure/telegram/commands/add-command-handler.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { Context } from 'telegraf'; -import { CallbackQuery } from 'telegraf/typings/core/types/typegram'; -import { BaseCommandHandler } from './base-command-handler'; -import { AccountingService } from '../../../domain/services/accounting.service'; -import { container } from '../../utils'; -import { Transaction } from '../../../domain/models/transaction'; -import { AccountName } from '../../../domain/models/account'; -import { Currency } from '../../../domain/models/types'; -import { Telegraf } from 'telegraf'; -import { CommandHandlers, UserState } from '../command-handlers'; -import { getCashAccount } from '../../utils/telegram'; -import { NLPService } from '../../../domain/services/nlp.service'; - -export class AddCommandHandler extends BaseCommandHandler { - private accountingService: AccountingService; - private nlpService: NLPService; - private bot: Telegraf; - private commandHandlers: CommandHandlers; - - constructor(bot: Telegraf, commandHandlers: CommandHandlers) { - super(); - this.bot = bot; - this.commandHandlers = commandHandlers; - this.accountingService = container.getByClass(AccountingService); - this.nlpService = container.getByClass(NLPService); - - // Set up callback query handler - this.bot.on('callback_query', async (ctx, next) => { - const handled = await this.handleCallbackQuery(ctx); - if (!handled) { - return next(); - } - }); - } - - async handle(ctx: Context, ...args: any[]): Promise { - const chatId = ctx.chat?.id.toString(); - if (!chatId) { - await ctx.reply('Error: Could not identify chat ID'); - return; - } - - // Set user state to adding bill - this.commandHandlers.setUserState(chatId, UserState.ADDING_BILL); - - await ctx.reply( - 'Please enter your expense information directly, for example:\n\n' + - '"Spent $50 on food at NTUC"\n' + - 'I will automatically parse and record this transaction.\n\n' + - 'Enter /cancel to cancel the operation.' - ); - } - - // Handle user messages, called by CommandHandlers - async handleMessage(ctx: Context): Promise { - const chatId = ctx.chat?.id.toString(); - if (!chatId) { - return false; // Don't process this message - } - - // Check if user is in adding bill state or idle state - const userState = this.commandHandlers.getUserState(chatId); - if (userState !== UserState.ADDING_BILL && userState !== UserState.IDLE) { - return false; // Don't process this message - } - - if (!ctx.message || !('text' in ctx.message)) { - return false; - } - - const text = ctx.message.text; - if (text.startsWith('/')) { - if (text === '/cancel') { - this.commandHandlers.resetUserState(chatId); - await ctx.reply('Operation cancelled.'); - return true; // Message handled - } - return false; // Don't process other commands - } - - try { - // If user is in IDLE state, set it to ADDING_BILL - if (userState === UserState.IDLE) { - this.commandHandlers.setUserState(chatId, UserState.ADDING_BILL); - } - - await this.processBillInput(ctx, text); - return true; // Message handled - } catch (error) { - this.logger.error('Error processing bill input:', error); - await ctx.reply('Sorry, there was an error processing your bill information. Please try again.'); - return true; // Message handled - } - } - - private generateTransactionMessage(transaction: Transaction, status?: string): string { - return ( - `Transaction Details ${status || ''}\n\n` + - `Amount: ${transaction.entries[1].amount.value} ${transaction.entries[0].amount.currency}\n` + - `Description: ${transaction.description}\n` + - `From: ${transaction.entries[0].account}\n` + - `TO: ${transaction.entries[1].account}` - ); - } - - private async processBillInput(ctx: Context, input: string): Promise { - const chatId = ctx.chat?.id.toString(); - if (!chatId) return; - - try { - // await ctx.reply('Processing your bill information...'); - - // Get the username from the message - const username = ctx.from?.username; - if (!username) { - await ctx.reply('Unable to identify user. Please ensure your Telegram account has a username set.'); - return; - } - - // Determine which account to use based on username - const account = getCashAccount(username); - if (!account) { - await ctx.reply('Sorry, you do not have permission to use this feature.'); - return; - } - - // Use NLP service to parse the input and create a transaction - const transactionData = await this.nlpService.parseExpenseInput(input); - - // Create transaction object - const transaction: Transaction = { - date: new Date(), - description: transactionData.description, - entries: [ - { - account: account, - amount: { - value: -transactionData.amount, - currency: transactionData.currency as Currency - } - }, - { - account: transactionData.category as AccountName, - amount: { - value: transactionData.amount, - currency: transactionData.currency as Currency - } - } - ], - metadata: { - username, - message: input, - } - }; - - // Send confirmation message with buttons - const message = this.generateTransactionMessage(transaction); - - // Store transaction data in user state for later use - this.commandHandlers.setTransactionData(chatId, transaction); - - // Send confirmation buttons - await ctx.reply(message, { - parse_mode: 'HTML', - reply_markup: { - inline_keyboard: [ - [ - { text: '✅ Confirm', callback_data: 'add_confirm' }, - { text: '❌ Cancel', callback_data: 'add_cancel' } - ] - ] - } - }); - } catch (error) { - this.logger.error('Error processing bill:', error); - await ctx.reply('Sorry, I cannot process your bill information. Please ensure the format is correct and try again.'); - } - } - - // Handle callback query for transaction confirmation - async handleCallbackQuery(ctx: Context): Promise { - const callbackQuery = ctx.callbackQuery as CallbackQuery.DataQuery; - if (!callbackQuery?.data) return false; - - // Only handle callbacks that start with 'add_' - if (!callbackQuery.data.startsWith('add_')) return false; - - const chatId = callbackQuery.message?.chat.id.toString(); - if (!chatId) return false; - - const transaction = this.commandHandlers.getTransactionData(chatId); - if (!transaction) { - await ctx.answerCbQuery('Transaction data expired, please enter again.'); - return true; - } - - if (callbackQuery.data === 'add_confirm') { - try { - // Save transaction - await this.accountingService.addTransaction(transaction); - - // Clear transaction data - this.commandHandlers.clearTransactionData(chatId); - - // Update the original message - const successMessage = this.generateTransactionMessage(transaction, '✅'); - await ctx.editMessageText(successMessage, { parse_mode: 'HTML' }); - await ctx.answerCbQuery('Transaction saved!'); - } catch (error) { - this.logger.error('Error saving transaction:', error); - await ctx.answerCbQuery('Error saving transaction, please try again.'); - } - } else if (callbackQuery.data === 'add_cancel') { - // Clear transaction data - this.commandHandlers.clearTransactionData(chatId); - - // Update the original message - const cancelMessage = this.generateTransactionMessage(transaction, '❌'); - await ctx.editMessageText(cancelMessage, { parse_mode: 'HTML' }); - await ctx.reply('Transaction cancelled.'); - } - - return true; - } -} \ No newline at end of file diff --git a/src/infrastructure/telegram/commands/base-command-handler.ts b/src/infrastructure/telegram/commands/base-command-handler.ts deleted file mode 100644 index bff6c20..0000000 --- a/src/infrastructure/telegram/commands/base-command-handler.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { ILogger, container, Logger } from '../../utils'; - -export abstract class BaseCommandHandler { - protected logger: ILogger; - - constructor() { - this.logger = container.getByClass(Logger); - } - - abstract handle(ctx: unknown, ...args: unknown[]): Promise; -} \ No newline at end of file diff --git a/src/infrastructure/telegram/commands/categorization-command-handler.ts b/src/infrastructure/telegram/commands/categorization-command-handler.ts deleted file mode 100644 index 1213f19..0000000 --- a/src/infrastructure/telegram/commands/categorization-command-handler.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { Context, Markup } from 'telegraf'; -import { CallbackQuery } from 'telegraf/typings/core/types/typegram'; -import { BaseCommandHandler } from './base-command-handler'; -import { NLPService } from '../../../domain/services/nlp.service'; -import { PendingCategorization } from '../types'; -import { Telegraf } from 'telegraf'; -import { ApplicationEventEmitter } from '../../events/event-emitter'; -import { container, ILogger, Logger } from '../../utils'; -import { - CALLBACK_PREFIXES, - MESSAGES, - CATEGORY_TYPES -} from './categorization-constants'; -import { - generateShortId, - createCategoryKeyboard, - createCategoryResultMessage, - getShortId, - findPendingCategorization -} from './categorization-utils'; -import { - CategorizationData, - CategorizationMap, - CategorySelectionEventData -} from './categorization-types'; -import { EventTypes } from '../../events/event-types'; -import { CommandHandlers, UserState } from '../command-handlers'; - -export class CategorizationCommandHandler extends BaseCommandHandler { - private nlpService: NLPService; - private pendingCategorizations: Map; - private bot: Telegraf; - private eventEmitter: ApplicationEventEmitter; - private categorizationMap: CategorizationMap; - private truncatedIdMap: Map = new Map(); // Map truncated IDs to full merchant IDs - protected logger: ILogger; - private commandHandlers: CommandHandlers; - - constructor( - bot: Telegraf, - commandHandlers: CommandHandlers - ) { - super(); - this.nlpService = container.getByClass(NLPService); - this.pendingCategorizations = new Map(); - this.bot = bot; - this.commandHandlers = commandHandlers; - this.eventEmitter = container.getByClass(ApplicationEventEmitter); - this.categorizationMap = new Map(); - this.logger = container.getByClass(Logger); - - // Set up callback query handler - this.bot.on('callback_query', async (ctx, next) => { - const handled = await this.handleCallbackQuery(ctx); - if (!handled) { - return next(); - } - }); - } - - // Handle callback query - async handleCallbackQuery(ctx: Context): Promise { - const callbackQuery = ctx.callbackQuery as CallbackQuery.DataQuery; - if (!callbackQuery?.data) return false; - - // Only handle callbacks that start with our prefixes - if (!callbackQuery.data.startsWith('sc:') && - !callbackQuery.data.startsWith('cc:') && - !callbackQuery.data.startsWith(CALLBACK_PREFIXES.CATEGORIZE_MERCHANT)) { - return false; - } - - try { - if (callbackQuery.data.startsWith(CALLBACK_PREFIXES.CATEGORIZE_MERCHANT)) { - const truncatedId = callbackQuery.data.slice(CALLBACK_PREFIXES.CATEGORIZE_MERCHANT.length); - await this.handleCategorizeMerchantCallback(ctx, truncatedId); - } else if (callbackQuery.data.startsWith('sc:')) { - const [, shortId, categoryType] = callbackQuery.data.split(':'); - await this.handleCategorySelection(ctx, shortId, categoryType); - } else if (callbackQuery.data.startsWith('cc:')) { - const shortId = callbackQuery.data.slice(3); - await this.handleCategoryCancel(ctx, shortId); - } - return true; - } catch (error) { - this.logger.error('Error handling callback query:', error); - await ctx.answerCbQuery('Sorry, I encountered an error while processing your request.'); - return true; - } - } - - // 实现抽象方法 - async handle(ctx: Context, ...args: any[]): Promise { - // 默认处理逻辑,可以根据需要扩展 - await ctx.reply('Categorization command received.'); - } - - // 获取待处理的分类 - getPendingCategorization(merchantId: string): PendingCategorization | undefined { - return this.pendingCategorizations.get(merchantId); - } - - // 删除待处理的分类 - removePendingCategorization(merchantId: string): void { - this.pendingCategorizations.delete(merchantId); - } - - // 添加短ID映射 - addTruncatedIdMapping(shortId: string, merchantId: string): void { - this.truncatedIdMap.set(shortId, merchantId); - } - - // 获取完整的商家ID - getFullMerchantId(truncatedId: string): string | undefined { - return this.truncatedIdMap.get(truncatedId); - } - - // 发送通知 - async sendNotification(chatId: string, message: string, merchantId?: string, categorizationData?: PendingCategorization): Promise { - if (!chatId) { - this.logger.warn('No chat ID provided for Telegram notification'); - return; - } - - try { - let keyboard; - if (merchantId) { - // 生成短ID - const shortId = getShortId(merchantId); - this.addTruncatedIdMapping(shortId, merchantId); - - keyboard = Markup.inlineKeyboard([ - Markup.button.callback('🤖 Categorize with AI', `${CALLBACK_PREFIXES.CATEGORIZE_MERCHANT}${shortId}`) - ]); - } - - await this.bot.telegram.sendMessage(chatId, message, { parse_mode: 'HTML', ...keyboard }); - - if (merchantId && categorizationData) { - this.pendingCategorizations.set(merchantId, categorizationData); - } - - this.logger.info(`Notification sent to chat ${chatId}`); - } catch (error) { - this.logger.error(`Failed to send notification to chat ${chatId}:`, error); - throw error; - } - } - - async handleCategorizeMerchantCallback(ctx: Context, truncatedId: string): Promise { - const fullMerchantId = this.getFullMerchantId(truncatedId); - if (!fullMerchantId) { - this.logger.error(MESSAGES.ERROR_NO_MAPPING_FOUND(truncatedId)); - await ctx.answerCbQuery(MESSAGES.ERROR_MERCHANT_ID_NOT_FOUND); - return; - } - - const pendingCategorization = this.getPendingCategorization(fullMerchantId); - - if (!pendingCategorization) { - await ctx.answerCbQuery(MESSAGES.ERROR_CATEGORIZATION_NOT_FOUND); - return; - } - - try { - // Remove the "Categorize with AI" button immediately after click - if (ctx.callbackQuery?.message?.message_id) { - await ctx.editMessageReplyMarkup({ inline_keyboard: [] }); - } - - await ctx.answerCbQuery(); - await ctx.reply(MESSAGES.CATEGORIZATION_PROMPT(pendingCategorization.merchant), { parse_mode: 'HTML' }); - - if (ctx.chat?.id) { - const chatId = ctx.chat.id.toString(); - // 设置用户状态为分类中 - this.commandHandlers.setUserState(chatId, UserState.CATEGORIZING); - pendingCategorization.chatId = chatId; - this.pendingCategorizations.set(fullMerchantId, pendingCategorization); - } - } catch (error) { - this.logger.error('Error handling AI categorization:', error); - await ctx.reply(MESSAGES.CATEGORIZATION_REQUEST_ERROR); - } - } - - async handleMessage(ctx: Context): Promise { - const chatId = ctx.chat?.id.toString(); - const username = ctx.from?.username || 'unknown'; - this.logger.debug(`Message received from chat ID: ${chatId}, username: ${username}`); - - if (!chatId || !ctx.message || !('text' in ctx.message)) { - this.logger.debug(`Skipping message: Invalid message format or missing chat ID`); - return; - } - - // 检查用户是否处于分类状态 - const userState = this.commandHandlers.getUserState(chatId); - if (userState === UserState.CATEGORIZING) { - const pendingCategorization = findPendingCategorization(this.pendingCategorizations, chatId); - - if (pendingCategorization) { - await this.processCategorizationRequest(ctx, pendingCategorization, ctx.message.text); - } - } - } - - private async processCategorizationRequest(ctx: Context, pendingCategorization: PendingCategorization, userInput: string): Promise { - try { - await ctx.reply(MESSAGES.ANALYZING); - - const categories = await this.nlpService.categorizeMerchant(pendingCategorization.merchant, userInput); - - const shortId = generateShortId(); - this.categorizationMap.set(shortId, { - merchantId: pendingCategorization.merchantId, - categories: { - primary: categories.primaryCategory, - alternative: categories.alternativeCategory, - suggested: categories.suggestedNewCategory - } - }); - - const keyboard = createCategoryKeyboard(shortId, { - primary: categories.primaryCategory, - alternative: categories.alternativeCategory, - suggested: categories.suggestedNewCategory - }); - - await ctx.reply( - createCategoryResultMessage(pendingCategorization.merchant, { - primary: categories.primaryCategory, - alternative: categories.alternativeCategory, - suggested: categories.suggestedNewCategory - }), - { reply_markup: keyboard } - ); - } catch (error) { - this.logger.error('Error processing categorization:', error); - await ctx.reply(MESSAGES.CATEGORIZATION_ERROR); - } - } - - private async validateCategoryContext(ctx: Context, shortId: string): Promise<{ - chatId: string; - merchantId: string; - pendingCategorization: PendingCategorization; - } | null> { - const chatId = ctx.chat?.id.toString(); - - if (!chatId) { - await ctx.answerCbQuery(MESSAGES.ERROR_CHAT_ID_NOT_FOUND); - return null; - } - - const categorizationData = this.categorizationMap.get(shortId); - if (!categorizationData) { - await ctx.answerCbQuery(MESSAGES.ERROR_CATEGORIZATION_NOT_FOUND); - return null; - } - - const { merchantId } = categorizationData; - const pendingCategorization = this.getPendingCategorization(merchantId); - if (!pendingCategorization) { - await ctx.answerCbQuery(MESSAGES.ERROR_CATEGORIZATION_NOT_FOUND); - return null; - } - - return { chatId, merchantId, pendingCategorization }; - } - - async handleCategorySelection(ctx: Context, shortId: string, categoryType: string): Promise { - const validationResult = await this.validateCategoryContext(ctx, shortId); - if (!validationResult) return; - - const { chatId, merchantId, pendingCategorization } = validationResult; - const categorizationData = this.categorizationMap.get(shortId)!; - const selectedCategory = categorizationData.categories[categoryType as keyof typeof categorizationData.categories]; - - if (!selectedCategory) { - await ctx.answerCbQuery(MESSAGES.ERROR_INVALID_CATEGORY_TYPE); - return; - } - - const eventData: CategorySelectionEventData = { - merchantId: pendingCategorization.merchantId, - merchant: pendingCategorization.merchant, - selectedCategory: selectedCategory, - timestamp: new Date().toISOString() - }; - - this.eventEmitter.emit(EventTypes.MERCHANT_CATEGORY_SELECTED, eventData); - - await ctx.editMessageText( - MESSAGES.CATEGORY_SELECTED(pendingCategorization.merchant, selectedCategory) - ); - - this.removePendingCategorization(merchantId); - this.commandHandlers.resetUserState(chatId); - this.categorizationMap.delete(shortId); - } - - async handleCategoryCancel(ctx: Context, shortId: string): Promise { - const validationResult = await this.validateCategoryContext(ctx, shortId); - if (!validationResult) return; - - const { chatId, merchantId, pendingCategorization } = validationResult; - - await ctx.editMessageText(MESSAGES.CATEGORIZATION_CANCELLED); - - this.eventEmitter.emit(EventTypes.MERCHANT_CATEGORY_SELECTED, { - merchantId: pendingCategorization.merchantId, - merchant: pendingCategorization.merchant, - selectedCategory: null, - timestamp: new Date().toISOString() - }); - - this.removePendingCategorization(merchantId); - this.commandHandlers.resetUserState(chatId); - this.categorizationMap.delete(shortId); - } -} \ No newline at end of file diff --git a/src/infrastructure/telegram/commands/categorization-types.ts b/src/infrastructure/telegram/commands/categorization-types.ts deleted file mode 100644 index f7536af..0000000 --- a/src/infrastructure/telegram/commands/categorization-types.ts +++ /dev/null @@ -1,22 +0,0 @@ -// 分类相关的类型定义 - -// 分类数据 -export interface CategorizationData { - merchantId: string; - categories: { - primary: string; - alternative: string; - suggested: string; - }; -} - -// 分类映射 -export type CategorizationMap = Map; - -// 分类选择事件数据 -export interface CategorySelectionEventData { - merchantId: string; - merchant: string; - selectedCategory: string; - timestamp: string; -} \ No newline at end of file diff --git a/src/infrastructure/telegram/commands/categorization-utils.ts b/src/infrastructure/telegram/commands/categorization-utils.ts deleted file mode 100644 index 6055b83..0000000 --- a/src/infrastructure/telegram/commands/categorization-utils.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { InlineKeyboardMarkup } from 'telegraf/typings/core/types/typegram'; -import { PendingCategorization } from '../types'; - -// 生成随机短ID -export function generateShortId(): string { - return Math.random().toString(36).substring(2, 8); -} - -// 生成商家ID的短哈希 -export function getShortId(merchantId: string): string { - // Generate a short hash of the merchantId - let hash = 0; - for (let i = 0; i < merchantId.length; i++) { - const char = merchantId.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - // Convert to base36 and take first 8 characters - return Math.abs(hash).toString(36).slice(0, 8); -} - -// 创建分类选择键盘 -export function createCategoryKeyboard( - shortId: string, - categories: { primary: string; alternative: string; suggested: string } -): InlineKeyboardMarkup { - return { - inline_keyboard: [ - [ - { text: `📁 ${categories.primary}`, callback_data: `sc:${shortId}:primary` } - ], - [ - { text: `📁 ${categories.alternative}`, callback_data: `sc:${shortId}:alternative` } - ], - [ - { text: `📁 ${categories.suggested}`, callback_data: `sc:${shortId}:suggested` } - ], - [ - { text: '❌ Cancel', callback_data: `cc:${shortId}` } - ] - ] - }; -} - -// 创建分类结果消息 -export function createCategoryResultMessage( - merchant: string, - categories: { primary: string; alternative: string; suggested: string } -): string { - return `I've analyzed "${merchant}" and found these possible categories:\n\n` + - `1. ${categories.primary}\n` + - `2. ${categories.alternative}\n` + - `3. ${categories.suggested}\n\n` + - `Please select the most appropriate category:`; -} - -// 查找待分类项 -export function findPendingCategorization( - pendingCategorizations: Map, - chatId: string -): PendingCategorization | undefined { - return Array.from(pendingCategorizations.entries()) - .find(([_, cat]) => cat.chatId === chatId)?.[1]; -} \ No newline at end of file diff --git a/src/infrastructure/telegram/commands/custom-query-command-handler.ts b/src/infrastructure/telegram/commands/custom-query-command-handler.ts index 9855743..69ed036 100644 --- a/src/infrastructure/telegram/commands/custom-query-command-handler.ts +++ b/src/infrastructure/telegram/commands/custom-query-command-handler.ts @@ -1,31 +1,31 @@ import { Bot } from 'grammy'; -import { BaseCommandHandler } from './base-command-handler'; import { BeancountQueryService } from '../../beancount/beancount-query.service'; -import { container } from '../../utils'; +import { container, Logger } from '../../utils'; +import { ILogger } from '../../utils'; import { formatQueryResult } from '../../utils/query-result-formatter'; import { NLPService } from '../../../domain/services/nlp.service'; import { formatDateToMMDD } from '../../utils/date.utils'; import { BotContext } from '../grammy-types'; -export class CustomQueryCommandHandler extends BaseCommandHandler { +export class CustomQueryCommandHandler { private bot: Bot; private beancountService: BeancountQueryService; private nlpService: NLPService; + private logger: ILogger; constructor(bot: Bot) { - super(); this.bot = bot; this.beancountService = container.getByClass(BeancountQueryService); this.nlpService = container.getByClass(NLPService); + this.logger = container.getByClass(Logger); } async handle(ctx: BotContext): Promise { const message = ctx.message; const userId = ctx.from?.id; - const text = message && 'text' in message ? message.text : undefined; + if (!text || !userId) { - this.logger.warn('Received message without text or userId'); return false; } @@ -36,15 +36,12 @@ export class CustomQueryCommandHandler extends BaseCommandHandler { this.logger.info(`Processing query from user ${userId}: ${text}`); try { - const queryText = text; - await ctx.reply('Analyzing your query...'); - this.logger.debug('Starting NLP analysis for query'); - const dateRange = await this.nlpService.parseDateRange(queryText); + const dateRange = await this.nlpService.parseDateRange(text); if (!dateRange) { - this.logger.warn(`Failed to parse date range from query: ${queryText}`); + this.logger.warn(`Failed to parse date range from query: ${text}`); await ctx.reply('Sorry, I couldn\'t understand your query. Please try to be more specific, for example: "查last 3 days", "查last week", "查last month"'); return true; } @@ -53,10 +50,7 @@ export class CustomQueryCommandHandler extends BaseCommandHandler { await ctx.reply(`Querying transactions from ${formatDateToMMDD(dateRange.startDate)} to ${formatDateToMMDD(dateRange.endDate)}...`); - this.logger.debug('Querying beancount service'); const result = await this.beancountService.queryByDateRange(dateRange.startDate, dateRange.endDate); - this.logger.info(`Query returned ${result.assets.length} assets and ${result.expenses.length} expense categories`); - const formattedMessage = formatQueryResult(result); await ctx.reply(formattedMessage, { parse_mode: 'HTML' }); return true; diff --git a/src/infrastructure/telegram/commands/query-command-handler.ts b/src/infrastructure/telegram/commands/query-command-handler.ts index e479000..d1599f7 100644 --- a/src/infrastructure/telegram/commands/query-command-handler.ts +++ b/src/infrastructure/telegram/commands/query-command-handler.ts @@ -1,7 +1,7 @@ import { Bot, InlineKeyboard } from 'grammy'; -import { BaseCommandHandler } from './base-command-handler'; import { BeancountQueryService } from '../../beancount/beancount-query.service'; -import { container } from '../../utils'; +import { container, Logger } from '../../utils'; +import { ILogger } from '../../utils'; import { formatQueryResult } from '../../utils/query-result-formatter'; import { BotContext } from '../grammy-types'; @@ -23,46 +23,29 @@ const TimeRangeDisplayText: Record = { [TimeRange.LAST_MONTH]: 'Last Month' }; -export class QueryCommandHandler extends BaseCommandHandler { +export class QueryCommandHandler { private bot: Bot; private beancountService: BeancountQueryService; + private logger: ILogger; constructor(bot: Bot) { - super(); this.bot = bot; this.beancountService = container.getByClass(BeancountQueryService); - this.registerCallbackHandlers(); + this.logger = container.getByClass(Logger); } - private registerCallbackHandlers(): void { + registerCallbackHandlers(): void { const timeRangeValues = Object.values(TimeRange); - const bot = this.bot as any; - - if (typeof bot.callbackQuery === 'function') { - // grammY path - bot.callbackQuery(timeRangeValues, async (ctx: BotContext) => { - try { - const timeRange = (ctx.callbackQuery as any).data as TimeRange; - await this.handleTimeRange(ctx, timeRange); - await (ctx as any).answerCallbackQuery(); - } catch (error) { - this.logger.error('Error handling time range selection:', error); - await ctx.reply('Sorry, I encountered an error while processing your selection.'); - } - }); - } else if (typeof bot.action === 'function') { - // Telegraf path (legacy — removed in Task 5) - bot.action(timeRangeValues, async (ctx: any) => { - try { - const timeRange = ctx.callbackQuery?.data as TimeRange; - await this.handleTimeRange(ctx, timeRange); - await ctx.answerCbQuery(); - } catch (error) { - this.logger.error('Error handling time range selection:', error); - await ctx.reply('Sorry, I encountered an error while processing your selection.'); - } - }); - } + this.bot.callbackQuery(timeRangeValues, async (ctx) => { + try { + const timeRange = ctx.callbackQuery.data as TimeRange; + await this.handleTimeRange(ctx, timeRange); + await ctx.answerCallbackQuery(); + } catch (error) { + this.logger.error('Error handling time range selection:', error); + await ctx.reply('Sorry, I encountered an error while processing your selection.'); + } + }); } async handle(ctx: BotContext): Promise { @@ -79,26 +62,14 @@ export class QueryCommandHandler extends BaseCommandHandler { await ctx.reply('Please select a time range:', { reply_markup: keyboard }); } - async handleTimeRange(ctx: BotContext, timeRange: TimeRange): Promise { + private async handleTimeRange(ctx: BotContext, timeRange: TimeRange): Promise { switch (timeRange) { - case TimeRange.TODAY: - await this.handleToday(ctx); - break; - case TimeRange.YESTERDAY: - await this.handleYesterday(ctx); - break; - case TimeRange.THIS_WEEK: - await this.handleThisWeek(ctx); - break; - case TimeRange.LAST_WEEK: - await this.handleLastWeek(ctx); - break; - case TimeRange.THIS_MONTH: - await this.handleThisMonth(ctx); - break; - case TimeRange.LAST_MONTH: - await this.handleLastMonth(ctx); - break; + case TimeRange.TODAY: return this.handleToday(ctx); + case TimeRange.YESTERDAY: return this.handleYesterday(ctx); + case TimeRange.THIS_WEEK: return this.handleThisWeek(ctx); + case TimeRange.LAST_WEEK: return this.handleLastWeek(ctx); + case TimeRange.THIS_MONTH: return this.handleThisMonth(ctx); + case TimeRange.LAST_MONTH: return this.handleLastMonth(ctx); } } diff --git a/src/infrastructure/telegram/conversations/add-bill.ts b/src/infrastructure/telegram/conversations/add-bill.ts index 3bf8aaf..5b80f03 100644 --- a/src/infrastructure/telegram/conversations/add-bill.ts +++ b/src/infrastructure/telegram/conversations/add-bill.ts @@ -29,7 +29,7 @@ interface ConfirmationResult { } async function waitForConfirmation( - conversation: Conversation, + conversation: Conversation, ): Promise { // eslint-disable-next-line no-constant-condition while (true) { @@ -53,7 +53,7 @@ async function waitForConfirmation( } export async function addBillConversation( - conversation: Conversation, + conversation: Conversation, ctx: BotContext, ): Promise { await ctx.reply( diff --git a/src/infrastructure/telegram/conversations/categorization.ts b/src/infrastructure/telegram/conversations/categorization.ts index 4f5c9fe..f4d454f 100644 --- a/src/infrastructure/telegram/conversations/categorization.ts +++ b/src/infrastructure/telegram/conversations/categorization.ts @@ -7,6 +7,7 @@ import { ApplicationEventEmitter } from '../../events/event-emitter'; import { EventTypes } from '../../events/event-types'; import { logger } from '../../utils/logger'; import { MESSAGES, CALLBACK_PREFIXES } from '../commands/categorization-constants'; +import { getPendingMerchant, removePendingMerchant } from '../telegram.adapter'; export const CATEGORIZATION_CONVERSATION_ID = 'categorization'; @@ -39,7 +40,7 @@ function emitCategorySelected(merchantId: string, merchant: string, selectedCate } export async function categorizationConversation( - conversation: Conversation, + conversation: Conversation, ctx: BotContext, ): Promise { // Extract merchantId from callback query data @@ -50,14 +51,16 @@ export async function categorizationConversation( return; } - // The callback data format is: CATEGORIZE_MERCHANT + merchantId (full, not truncated). - // Task 6 wires the notification to use this format and populate session.pendingMerchants. - const merchantId = callbackData.slice(CALLBACK_PREFIXES.CATEGORIZE_MERCHANT.length); + // The callback data contains a short hash ID to stay within Telegram's 64-byte limit. + // The full merchant data is looked up from the pending merchant registry. + const shortId = callbackData.slice(CALLBACK_PREFIXES.CATEGORIZE_MERCHANT.length); - // Look up the real merchant name from session data (populated by Task 6) - const pendingMerchants = ctx.session.pendingMerchants || {}; - const merchantData = pendingMerchants[merchantId]; - const merchant = merchantData?.merchant || merchantId; + const registryData = await conversation.external(() => getPendingMerchant(shortId)); + if (!registryData) { + await ctx.answerCallbackQuery(MESSAGES.ERROR_MERCHANT_ID_NOT_FOUND); + return; + } + const { merchantId, merchant } = registryData; // Remove the "Categorize with AI" button try { @@ -76,13 +79,18 @@ export async function categorizationConversation( if (!userInput || userInput === '/cancel') { await contextCtx.reply(MESSAGES.CATEGORIZATION_CANCELLED); - await conversation.external(() => emitCategorySelected(merchantId, merchant, '')); + await conversation.external(() => { + emitCategorySelected(merchantId, merchant, ''); + removePendingMerchant(shortId); + }); return; } if (userInput.startsWith('/')) { - // Other commands: emit cancellation to unblock queue, then pass update through - await conversation.external(() => emitCategorySelected(merchantId, merchant, '')); + await conversation.external(() => { + emitCategorySelected(merchantId, merchant, ''); + removePendingMerchant(shortId); + }); await conversation.skip({ next: true }); return; } @@ -99,8 +107,10 @@ export async function categorizationConversation( } catch (error) { logger.error('Error categorizing merchant:', error); await contextCtx.reply(MESSAGES.CATEGORIZATION_ERROR); - // Emit cancellation to unblock the queue - await conversation.external(() => emitCategorySelected(merchantId, merchant, '')); + await conversation.external(() => { + emitCategorySelected(merchantId, merchant, ''); + removePendingMerchant(shortId); + }); return; } @@ -149,8 +159,6 @@ export async function categorizationConversation( // Emit event for category selection (or cancellation) await conversation.external(() => emitCategorySelected(merchantId, merchant, selectedCategory)); - // Clean up session data - if (ctx.session.pendingMerchants) { - delete ctx.session.pendingMerchants[merchantId]; - } + // Clean up + await conversation.external(() => removePendingMerchant(shortId)); } diff --git a/src/infrastructure/telegram/telegram.adapter.ts b/src/infrastructure/telegram/telegram.adapter.ts index aae7450..8760bad 100644 --- a/src/infrastructure/telegram/telegram.adapter.ts +++ b/src/infrastructure/telegram/telegram.adapter.ts @@ -1,37 +1,62 @@ -import { Telegraf } from 'telegraf'; +import { Bot } from 'grammy'; +import { createConversation } from '@grammyjs/conversations'; import { ILogger, container, Logger } from '../utils'; -import { CommandHandlers } from './command-handlers'; -import { PendingCategorization } from './types'; +import { BotContext } from './grammy-types'; +import { createBot } from './bot'; +import { addBillConversation, ADD_BILL_CONVERSATION_ID } from './conversations/add-bill'; +import { categorizationConversation, CATEGORIZATION_CONVERSATION_ID } from './conversations/categorization'; +import { QueryCommandHandler } from './commands/query-command-handler'; +import { CustomQueryCommandHandler } from './commands/custom-query-command-handler'; +import { CALLBACK_PREFIXES } from './commands/categorization-constants'; + +// Short ID → full merchant data mapping for callback data (Telegram 64-byte limit) +const pendingMerchantRegistry = new Map(); +const shortIdToMerchantId = new Map(); + +function generateShortId(merchantId: string): string { + let hash = 0; + for (let i = 0; i < merchantId.length; i++) { + const char = merchantId.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + return Math.abs(hash).toString(36).slice(0, 8); +} + +export function getPendingMerchant(shortId: string): { merchantId: string; merchant: string } | undefined { + const merchantId = shortIdToMerchantId.get(shortId); + if (!merchantId) return undefined; + return pendingMerchantRegistry.get(merchantId); +} + +export function removePendingMerchant(shortId: string): void { + const merchantId = shortIdToMerchantId.get(shortId); + if (merchantId) { + pendingMerchantRegistry.delete(merchantId); + shortIdToMerchantId.delete(shortId); + } +} export class TelegramAdapter { - private bot: Telegraf; + private bot: Bot; private logger: ILogger; private chatId: string; - private commandHandlers: CommandHandlers; constructor() { this.logger = container.getByClass(Logger); - + const token = process.env.TELEGRAM_BOT_TOKEN; if (!token) { this.logger.error('TELEGRAM_BOT_TOKEN is required in environment variables'); throw new Error('TELEGRAM_BOT_TOKEN is required in environment variables'); } - - try { - this.bot = new Telegraf(token); - } catch (error) { - this.logger.error('Failed to create Telegraf instance:', error); - throw error; - } - - try { - this.commandHandlers = new CommandHandlers(this.bot); - } catch (error) { - this.logger.error('Failed to initialize CommandHandlers:', error); - throw error; - } - + + const sessionDir = process.env.SESSION_DIR || './data/sessions'; + this.bot = createBot({ token, sessionDir }); + + this.setupConversations(); + this.setupCommandHandlers(); + const chatId = process.env.TELEGRAM_CHAT_ID; if (!chatId) { this.logger.warn('TELEGRAM_CHAT_ID not found in environment variables'); @@ -39,39 +64,58 @@ export class TelegramAdapter { this.chatId = chatId || ''; } - private async setupCommands(): Promise { - try { - await this.bot.telegram.setMyCommands([ - { command: 'start', description: 'Start the bot' }, - { command: 'add', description: 'Add a new bill' }, - { command: 'query', description: 'Query transactions' } - ]); - } catch (error) { - this.logger.error('Failed to set up bot commands:', error); - } + private setupConversations(): void { + this.bot.use(createConversation(addBillConversation, ADD_BILL_CONVERSATION_ID)); + this.bot.use(createConversation(categorizationConversation, CATEGORIZATION_CONVERSATION_ID)); + } + + private setupCommandHandlers(): void { + // /add enters the add-bill conversation + this.bot.command('add', async (ctx) => { + await ctx.conversation.enter(ADD_BILL_CONVERSATION_ID); + }); + + // /query shows time range selection + const queryHandler = new QueryCommandHandler(this.bot); + queryHandler.registerCallbackHandlers(); + this.bot.command('query', async (ctx) => { + await queryHandler.handle(ctx); + }); + + // /cancel — handled within conversations; standalone cancel does nothing + this.bot.command('cancel', async (ctx) => { + await ctx.reply('No active operation to cancel.'); + }); + + // Categorize merchant button → enters categorization conversation + this.bot.callbackQuery(new RegExp(`^${CALLBACK_PREFIXES.CATEGORIZE_MERCHANT}`), async (ctx) => { + await ctx.conversation.enter(CATEGORIZATION_CONVERSATION_ID); + }); + + // Custom query — free text starting with 查 + const customQueryHandler = new CustomQueryCommandHandler(this.bot); + this.bot.on('message:text', async (ctx) => { + await customQueryHandler.handle(ctx); + }); } async init(): Promise { try { - // Add error handler for the bot - this.bot.catch((err: any) => { + this.bot.catch((err) => { this.logger.error('Bot error occurred:', err); }); - // Try to get bot info before launch - try { - await this.bot.telegram.getMe(); - } catch (error) { - this.logger.error('Failed to get bot info:', error); - } - - // Set up bot commands - await this.setupCommands(); + await this.bot.api.setMyCommands([ + { command: 'start', description: 'Start the bot' }, + { command: 'add', description: 'Add a new bill' }, + { command: 'query', description: 'Query transactions' }, + ]); - // Launch the bot - await this.bot.launch(); + // bot.start() begins long polling and never resolves — do not await + this.bot.start({ + onStart: () => this.logger.info('Telegram bot polling started'), + }); - // Enable graceful stop process.once('SIGINT', () => this.bot.stop()); process.once('SIGTERM', () => this.bot.stop()); } catch (error) { @@ -80,7 +124,7 @@ export class TelegramAdapter { } } - async sendNotification(message: string, merchantId?: string, categorizationData?: PendingCategorization): Promise { + async sendNotification(message: string, merchantId?: string, categorizationData?: { merchant?: string; merchantId?: string }): Promise { if (!this.chatId) { this.logger.warn('No chat ID configured for Telegram notifications'); return; @@ -93,24 +137,37 @@ export class TelegramAdapter { while (retryCount < maxRetries) { try { - await this.commandHandlers.sendNotification( - this.chatId, - message, - merchantId, - categorizationData - ); - return; // Success, exit the function + if (merchantId) { + const merchant = categorizationData?.merchant || merchantId; + pendingMerchantRegistry.set(merchantId, { merchantId, merchant }); + const shortId = generateShortId(merchantId); + shortIdToMerchantId.set(shortId, merchantId); + + await this.bot.api.sendMessage(this.chatId, message, { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: '🤖 Categorize with AI', callback_data: `${CALLBACK_PREFIXES.CATEGORIZE_MERCHANT}${shortId}` }, + ]], + }, + }); + } else { + await this.bot.api.sendMessage(this.chatId, message, { parse_mode: 'HTML' }); + } + this.logger.info(`Notification sent to chat ${this.chatId}`); + return; } catch (error) { retryCount++; - this.logger.error(`Failed to send notification to chat ${this.chatId} (attempt ${retryCount}/${maxRetries}):`, error); - + this.logger.error(`Failed to send notification (attempt ${retryCount}/${maxRetries}):`, error); if (retryCount === maxRetries) { this.logger.error('Maximum retry attempts reached, giving up'); } - - // Wait for 10 seconds before retrying await new Promise(resolve => setTimeout(resolve, 10000)); } } } -} \ No newline at end of file + + getBotInstance(): Bot { + return this.bot; + } +} diff --git a/src/infrastructure/telegram/types.ts b/src/infrastructure/telegram/types.ts index d8a4f0c..b3030d1 100644 --- a/src/infrastructure/telegram/types.ts +++ b/src/infrastructure/telegram/types.ts @@ -1,5 +1,4 @@ import { Email } from "../gmail/gmail.adapter"; -import { Message } from 'telegraf/typings/core/types/typegram'; export interface PendingCategorization { merchantId: string; @@ -7,6 +6,4 @@ export interface PendingCategorization { timestamp: string; chatId?: string; email?: Email; -} - -export type TextMessage = Message.TextMessage; \ No newline at end of file +}