package telegram import ( "context" "fmt" "io" "net/http" "strings" "time" "github.com/google/uuid" "go.uber.org/zap" tele "gopkg.in/telebot.v3" "gopkg.in/telebot.v3/middleware" "rmser/config" "rmser/internal/domain/account" "rmser/internal/infrastructure/rms" "rmser/internal/services/ocr" "rmser/internal/services/sync" "rmser/pkg/crypto" "rmser/pkg/logger" ) type Bot struct { b *tele.Bot ocrService *ocr.Service syncService *sync.Service accountRepo account.Repository rmsFactory *rms.Factory cryptoManager *crypto.CryptoManager fsm *StateManager adminIDs map[int64]struct{} webAppURL string // UI Elements (Menus) menuMain *tele.ReplyMarkup menuServers *tele.ReplyMarkup menuDicts *tele.ReplyMarkup menuBalance *tele.ReplyMarkup } func NewBot( cfg config.TelegramConfig, ocrService *ocr.Service, syncService *sync.Service, accountRepo account.Repository, rmsFactory *rms.Factory, cryptoManager *crypto.CryptoManager, ) (*Bot, error) { pref := tele.Settings{ Token: cfg.Token, Poller: &tele.LongPoller{Timeout: 10 * time.Second}, OnError: func(err error, c tele.Context) { logger.Log.Error("Telegram error", zap.Error(err)) }, } b, err := tele.NewBot(pref) if err != nil { return nil, err } admins := make(map[int64]struct{}) for _, id := range cfg.AdminIDs { admins[id] = struct{}{} } bot := &Bot{ b: b, ocrService: ocrService, syncService: syncService, accountRepo: accountRepo, rmsFactory: rmsFactory, cryptoManager: cryptoManager, fsm: NewStateManager(), adminIDs: admins, webAppURL: cfg.WebAppURL, } if bot.webAppURL == "" { bot.webAppURL = "http://example.com" } bot.initMenus() bot.initHandlers() return bot, nil } // initMenus инициализирует статические кнопки func (bot *Bot) initMenus() { // --- MAIN MENU --- bot.menuMain = &tele.ReplyMarkup{} btnServers := bot.menuMain.Data("🖥 Серверы", "nav_servers") btnDicts := bot.menuMain.Data("🔄 Справочники", "nav_dicts") btnBalance := bot.menuMain.Data("💰 Баланс", "nav_balance") btnApp := bot.menuMain.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL}) bot.menuMain.Inline( bot.menuMain.Row(btnServers, btnDicts), bot.menuMain.Row(btnBalance), bot.menuMain.Row(btnApp), ) // --- SERVERS MENU (Dynamic part logic is in handler) --- bot.menuServers = &tele.ReplyMarkup{} // --- DICTIONARIES MENU --- bot.menuDicts = &tele.ReplyMarkup{} btnSync := bot.menuDicts.Data("⚡️ Обновить данные", "act_sync") btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main") bot.menuDicts.Inline( bot.menuDicts.Row(btnSync), bot.menuDicts.Row(btnBack), ) // --- BALANCE MENU --- bot.menuBalance = &tele.ReplyMarkup{} btnDeposit := bot.menuBalance.Data("💳 Пополнить (Demo)", "act_deposit") bot.menuBalance.Inline( bot.menuBalance.Row(btnDeposit), bot.menuBalance.Row(btnBack), ) } func (bot *Bot) initHandlers() { bot.b.Use(middleware.Logger()) bot.b.Use(bot.registrationMiddleware) // Commands bot.b.Handle("/start", bot.renderMainMenu) // Navigation Callbacks bot.b.Handle(&tele.Btn{Unique: "nav_main"}, bot.renderMainMenu) bot.b.Handle(&tele.Btn{Unique: "nav_servers"}, bot.renderServersMenu) bot.b.Handle(&tele.Btn{Unique: "nav_dicts"}, bot.renderDictsMenu) bot.b.Handle(&tele.Btn{Unique: "nav_balance"}, bot.renderBalanceMenu) // Actions Callbacks bot.b.Handle(&tele.Btn{Unique: "act_add_server"}, bot.startAddServerFlow) bot.b.Handle(&tele.Btn{Unique: "act_sync"}, bot.triggerSync) bot.b.Handle(&tele.Btn{Unique: "act_del_server_menu"}, bot.renderDeleteServerMenu) bot.b.Handle(&tele.Btn{Unique: "confirm_name_yes"}, bot.handleConfirmNameYes) bot.b.Handle(&tele.Btn{Unique: "confirm_name_no"}, bot.handleConfirmNameNo) bot.b.Handle(&tele.Btn{Unique: "act_deposit"}, func(c tele.Context) error { return c.Respond(&tele.CallbackResponse{Text: "Функция пополнения в разработке 🛠"}) }) // Dynamic Handler for server selection ("set_server_UUID") bot.b.Handle(tele.OnCallback, bot.handleCallback) // Input Handlers bot.b.Handle(tele.OnText, bot.handleText) bot.b.Handle(tele.OnPhoto, bot.handlePhoto) } func (bot *Bot) Start() { logger.Log.Info("Запуск Telegram бота...") bot.b.Start() } func (bot *Bot) Stop() { bot.b.Stop() } func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc { return func(c tele.Context) error { user := c.Sender() _, err := bot.accountRepo.GetOrCreateUser(user.ID, user.Username, user.FirstName, user.LastName) if err != nil { logger.Log.Error("Failed to register user", zap.Error(err)) } return next(c) } } // --- RENDERERS (View Layer) --- func (bot *Bot) renderMainMenu(c tele.Context) error { // Сбрасываем стейты FSM, если пользователь вернулся в меню bot.fsm.Reset(c.Sender().ID) txt := "👋 Панель управления RMSER\n\n" + "Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников." return c.EditOrSend(txt, bot.menuMain, tele.ModeHTML) } func (bot *Bot) renderServersMenu(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) servers, err := bot.accountRepo.GetAllServers(userDB.ID) if err != nil { return c.Send("Ошибка БД: " + err.Error()) } menu := &tele.ReplyMarkup{} var rows []tele.Row // Генерируем кнопки для каждого сервера for _, s := range servers { icon := "🔴" if s.IsActive { icon = "🟢" } // Payload: "set_server_" btn := menu.Data(fmt.Sprintf("%s %s", icon, s.Name), "set_server_"+s.ID.String()) rows = append(rows, menu.Row(btn)) } btnAdd := menu.Data("➕ Добавить сервер", "act_add_server") btnDel := menu.Data("🗑 Удалить", "act_del_server_menu") btnBack := menu.Data("🔙 Назад", "nav_main") rows = append(rows, menu.Row(btnAdd, btnDel)) rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) txt := fmt.Sprintf("🖥 Ваши серверы (%d):\n\nНажмите на сервер, чтобы сделать его активным.", len(servers)) return c.EditOrSend(txt, menu, tele.ModeHTML) } func (bot *Bot) renderDictsMenu(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) stats, err := bot.syncService.GetSyncStats(userDB.ID) var txt string if err != nil { txt = fmt.Sprintf("⚠️ Статус: Ошибка (%v)", err) } else { lastUpdate := "—" if stats.LastInvoice != nil { lastUpdate = stats.LastInvoice.Format("02.01.2006") } txt = fmt.Sprintf("🔄 Состояние справочников\n\n"+ "🏢 Сервер: %s\n"+ "📦 Товары: %d\n"+ "🚚 Поставщики: %d\n"+ "🏭 Склады: %d\n\n"+ "📄 Накладные (30дн): %d\n"+ "📅 Посл. документ: %s\n\n"+ "Нажмите «Обновить», чтобы синхронизировать данные.", stats.ServerName, stats.ProductsCount, stats.SuppliersCount, stats.StoresCount, stats.InvoicesLast30, lastUpdate) } return c.EditOrSend(txt, bot.menuDicts, tele.ModeHTML) } func (bot *Bot) renderBalanceMenu(c tele.Context) error { // Заглушка баланса txt := "💰 Ваш баланс\n\n" + "💵 Текущий счет: 0.00 ₽\n" + "💎 Тариф: Free\n\n" + "Пока сервис работает в бета-режиме, использование бесплатно." return c.EditOrSend(txt, bot.menuBalance, tele.ModeHTML) } // --- LOGIC HANDLERS --- func (bot *Bot) handleCallback(c tele.Context) error { data := c.Callback().Data // FIX: Telebot v3 добавляет префикс '\f' к Unique ID кнопки. // Нам нужно удалить его, чтобы корректно парсить строку. if len(data) > 0 && data[0] == '\f' { data = data[1:] } // Обработка выбора сервера "set_server_..." if strings.HasPrefix(data, "set_server_") { serverIDStr := strings.TrimPrefix(data, "set_server_") serverIDStr = strings.TrimSpace(serverIDStr) // Защита от старых форматов с разделителем | if idx := strings.Index(serverIDStr, "|"); idx != -1 { serverIDStr = serverIDStr[:idx] } userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) // 1. Ищем сервер в базе, чтобы убедиться что это сервер этого юзера servers, _ := bot.accountRepo.GetAllServers(userDB.ID) var found bool for _, s := range servers { if s.ID.String() == serverIDStr { found = true break } } if !found { logger.Log.Warn("User tried to select unknown server", zap.Int64("user_tg_id", c.Sender().ID), zap.String("server_id_req", serverIDStr)) return c.Respond(&tele.CallbackResponse{Text: "Сервер не найден или доступ запрещен"}) } // 2. Делаем активным targetID := parseUUID(serverIDStr) if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil { logger.Log.Error("Failed to set active server", zap.Error(err)) return c.Respond(&tele.CallbackResponse{Text: "Ошибка смены сервера"}) } // 3. Успех c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"}) return bot.renderServersMenu(c) // Перерисовываем меню } // --- ЛОГИКА УДАЛЕНИЯ (новая) --- if strings.HasPrefix(data, "do_del_server_") { serverIDStr := strings.TrimPrefix(data, "do_del_server_") serverIDStr = strings.TrimSpace(serverIDStr) // Очистка от мусора if idx := strings.Index(serverIDStr, "|"); idx != -1 { serverIDStr = serverIDStr[:idx] } targetID := parseUUID(serverIDStr) if targetID == uuid.Nil { return c.Respond(&tele.CallbackResponse{Text: "Некорректный ID"}) } // 1. Проверяем, активен ли он сейчас // Нам нужно знать это ДО удаления, чтобы переключить активность // Но проще удалить, а потом проверить, остался ли активный сервер // Удаляем if err := bot.accountRepo.DeleteServer(targetID); err != nil { logger.Log.Error("Failed to delete server", zap.Error(err)) return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"}) } // Сбрасываем кэш клиента в фабрике bot.rmsFactory.ClearCache(targetID) // 2. Проверяем, есть ли активный сервер у пользователя userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) active, err := bot.accountRepo.GetActiveServer(userDB.ID) // Если активного нет (мы удалили активный) или ошибка - назначаем новый if active == nil || err != nil { all, _ := bot.accountRepo.GetAllServers(userDB.ID) if len(all) > 0 { // Делаем активным первый попавшийся _ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID) c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Активным назначен " + all[0].Name}) } else { c.Respond(&tele.CallbackResponse{Text: "Сервер удален. Список пуст."}) } } else { c.Respond(&tele.CallbackResponse{Text: "Сервер удален"}) } // Возвращаемся в меню удаления (обновляем список) return bot.renderDeleteServerMenu(c) } return nil } func (bot *Bot) triggerSync(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."}) // Запускаем в фоне, но уведомляем юзера go func() { if err := bot.syncService.SyncAllData(userDB.ID); err != nil { logger.Log.Error("Manual sync failed", zap.Error(err)) bot.b.Send(c.Sender(), "❌ Ошибка синхронизации. Проверьте настройки сервера.") } else { bot.b.Send(c.Sender(), "✅ Синхронизация успешно завершена!") } }() return nil } // --- FSM: ADD SERVER FLOW --- func (bot *Bot) startAddServerFlow(c tele.Context) error { bot.fsm.SetState(c.Sender().ID, StateAddServerURL) return c.EditOrSend("🔗 Введите URL вашего сервера iikoRMS.\nПример: https://iiko.myrest.ru:443\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML) } func (bot *Bot) handleText(c tele.Context) error { userID := c.Sender().ID state := bot.fsm.GetState(userID) text := strings.TrimSpace(c.Text()) // Глобальная отмена if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" { bot.fsm.Reset(userID) return bot.renderMainMenu(c) } if state == StateNone { return c.Send("Используйте меню для навигации 👇") } switch state { case StateAddServerURL: if !strings.HasPrefix(text, "http") { return c.Send("❌ URL должен начинаться с http:// или https://\nПопробуйте снова.") } bot.fsm.UpdateContext(userID, func(ctx *UserContext) { ctx.TempURL = strings.TrimRight(text, "/") ctx.State = StateAddServerLogin }) return c.Send("👤 Введите логин пользователя iiko:") case StateAddServerLogin: bot.fsm.UpdateContext(userID, func(ctx *UserContext) { ctx.TempLogin = text ctx.State = StateAddServerPassword }) return c.Send("🔑 Введите пароль:") case StateAddServerPassword: password := text ctx := bot.fsm.GetContext(userID) msg, _ := bot.b.Send(c.Sender(), "⏳ Проверяю подключение...") // 1. Проверяем авторизацию (креды) tempClient := bot.rmsFactory.CreateClientFromRawCredentials(ctx.TempURL, ctx.TempLogin, password) if err := tempClient.Auth(); err != nil { bot.b.Delete(msg) return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err)) } // 2. Пробуем узнать имя сервера var detectedName string info, err := rms.GetServerInfo(ctx.TempURL) if err == nil && info.ServerName != "" { detectedName = info.ServerName } bot.b.Delete(msg) // Сохраняем пароль во временный контекст, он нам пригодится при финальном сохранении bot.fsm.UpdateContext(userID, func(uCtx *UserContext) { uCtx.TempPassword = password uCtx.TempServerName = detectedName }) // Если имя нашли - предлагаем выбор if detectedName != "" { bot.fsm.SetState(userID, StateAddServerConfirmName) menu := &tele.ReplyMarkup{} btnYes := menu.Data("✅ Да, использовать это имя", "confirm_name_yes") btnNo := menu.Data("✏️ Ввести другое", "confirm_name_no") menu.Inline(menu.Row(btnYes), menu.Row(btnNo)) return c.Send(fmt.Sprintf("🔎 Обнаружено имя сервера: %s.\nИспользовать его?", detectedName), menu, tele.ModeHTML) } // Если имя не нашли - просим ввести вручную bot.fsm.SetState(userID, StateAddServerInputName) return c.Send("🏷 Введите название для этого сервера (для вашего удобства):") case StateAddServerInputName: // Пользователь ввел свое название name := text if len(name) < 3 { return c.Send("⚠️ Название слишком короткое.") } return bot.saveServerFinal(c, userID, name) } return nil } func (bot *Bot) handlePhoto(c tele.Context) error { userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "") if err != nil { return c.Send("Ошибка базы данных пользователей") } _, err = bot.rmsFactory.GetClientForUser(userDB.ID) if err != nil { return c.Send("⛔ У вас не настроен сервер iiko.\nИспользуйте /add_server для настройки.") } photo := c.Message().Photo file, err := bot.b.FileByID(photo.FileID) if err != nil { return c.Send("Ошибка доступа к файлу.") } fileURL := fmt.Sprintf("https://api.telegram.org/file/bot%s/%s", bot.b.Token, file.FilePath) resp, err := http.Get(fileURL) if err != nil { return c.Send("Ошибка скачивания файла.") } defer resp.Body.Close() imgData, err := io.ReadAll(resp.Body) if err != nil { return c.Send("Ошибка чтения файла.") } c.Send("⏳ Обрабатываю чек: создаю черновик и распознаю...") ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() draft, err := bot.ocrService.ProcessReceiptImage(ctx, userDB.ID, imgData) if err != nil { logger.Log.Error("OCR processing failed", zap.Error(err)) return c.Send("❌ Ошибка обработки: " + err.Error()) } matchedCount := 0 for _, item := range draft.Items { if item.IsMatched { matchedCount++ } } baseURL := strings.TrimRight(bot.webAppURL, "/") fullURL := fmt.Sprintf("%s/invoice/%s", baseURL, draft.ID.String()) var msgText string if matchedCount == len(draft.Items) { msgText = fmt.Sprintf("✅ Успех! Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items)) } else { msgText = fmt.Sprintf("⚠️ Внимание! Распознано %d из %d позиций.\n\nНекоторые товары требуют ручного сопоставления.", matchedCount, len(draft.Items)) } menu := &tele.ReplyMarkup{} btnOpen := menu.WebApp("📝 Открыть накладную", &tele.WebApp{URL: fullURL}) menu.Inline(menu.Row(btnOpen)) return c.Send(msgText, menu, tele.ModeHTML) } func parseUUID(s string) uuid.UUID { id, _ := uuid.Parse(s) return id } func (bot *Bot) handleConfirmNameYes(c tele.Context) error { userID := c.Sender().ID ctx := bot.fsm.GetContext(userID) if ctx.State != StateAddServerConfirmName { return c.Respond() } return bot.saveServerFinal(c, userID, ctx.TempServerName) } func (bot *Bot) handleConfirmNameNo(c tele.Context) error { userID := c.Sender().ID bot.fsm.SetState(userID, StateAddServerInputName) return c.EditOrSend("🏷 Хорошо, введите желаемое название:") } // saveServerFinal - общая логика сохранения в БД func (bot *Bot) saveServerFinal(c tele.Context, userID int64, serverName string) error { ctx := bot.fsm.GetContext(userID) encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword) userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "") newServer := &account.RMSServer{ UserID: userDB.ID, Name: serverName, BaseURL: ctx.TempURL, Login: ctx.TempLogin, EncryptedPassword: encPass, IsActive: true, } if err := bot.accountRepo.SaveServer(newServer); err != nil { return c.Send("Ошибка сохранения в БД: " + err.Error()) } bot.accountRepo.SetActiveServer(userDB.ID, newServer.ID) bot.fsm.Reset(userID) c.Send(fmt.Sprintf("✅ Сервер %s успешно добавлен!", serverName), tele.ModeHTML) // Auto-sync go bot.syncService.SyncAllData(userDB.ID) return bot.renderMainMenu(c) } func (bot *Bot) renderDeleteServerMenu(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) servers, err := bot.accountRepo.GetAllServers(userDB.ID) if err != nil { return c.Send("Ошибка БД: " + err.Error()) } if len(servers) == 0 { return c.Respond(&tele.CallbackResponse{Text: "Список серверов пуст"}) } menu := &tele.ReplyMarkup{} var rows []tele.Row for _, s := range servers { // Кнопка удаления для каждого сервера // Префикс do_del_server_ btn := menu.Data(fmt.Sprintf("❌ %s", s.Name), "do_del_server_"+s.ID.String()) rows = append(rows, menu.Row(btn)) } btnBack := menu.Data("🔙 Назад к списку", "nav_servers") rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) return c.EditOrSend("🗑 Удаление сервера\n\nНажмите на сервер, который хотите удалить.\nЭто действие нельзя отменить.", menu, tele.ModeHTML) }