// internal/transport/telegram/bot.go package telegram import ( "context" "fmt" "io" "net/http" "path/filepath" "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/billing" draftsService "rmser/internal/services/drafts" "rmser/internal/services/ocr" "rmser/internal/services/sync" "rmser/pkg/crypto" "rmser/pkg/logger" ) const DraftEditorPageSize = 10 // shortUUID возвращает первые 8 символов UUID для callback data func shortUUID(id uuid.UUID) string { s := id.String() if len(s) >= 8 { return s[:8] } return s } type Bot struct { b *tele.Bot ocrService *ocr.Service syncService *sync.Service billingService *billing.Service accountRepo account.Repository rmsFactory *rms.Factory cryptoManager *crypto.CryptoManager draftsService *draftsService.Service draftEditor *DraftEditor fsm *StateManager adminIDs map[int64]struct{} devIDs map[int64]struct{} maintenanceMode bool webAppURL string menuServers *tele.ReplyMarkup menuDicts *tele.ReplyMarkup menuBalance *tele.ReplyMarkup } func NewBot( cfg config.TelegramConfig, ocrService *ocr.Service, syncService *sync.Service, billingService *billing.Service, accountRepo account.Repository, rmsFactory *rms.Factory, cryptoManager *crypto.CryptoManager, draftsService *draftsService.Service, maintenanceMode bool, devIDs []int64, ) (*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{}{} } devs := make(map[int64]struct{}) for _, id := range devIDs { devs[id] = struct{}{} } bot := &Bot{ b: b, ocrService: ocrService, syncService: syncService, billingService: billingService, accountRepo: accountRepo, rmsFactory: rmsFactory, cryptoManager: cryptoManager, draftsService: draftsService, fsm: NewStateManager(), adminIDs: admins, devIDs: devs, maintenanceMode: maintenanceMode, webAppURL: cfg.WebAppURL, } if bot.webAppURL == "" { bot.webAppURL = "http://example.com" } bot.draftEditor = NewDraftEditor(bot.b, bot.draftsService, bot.accountRepo, bot.fsm) bot.initMenus() bot.initHandlers() return bot, nil } func (bot *Bot) isDev(userID int64) bool { _, ok := bot.devIDs[userID] return !bot.maintenanceMode || ok } func (bot *Bot) initMenus() { bot.menuServers = &tele.ReplyMarkup{} bot.menuDicts = &tele.ReplyMarkup{} btnSync := bot.menuDicts.Data("⚡️ Быстрое обновление", "act_sync") btnFullSync := bot.menuDicts.Data("♻️ Полная перезагрузка", "act_full_sync") btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main") bot.menuDicts.Inline( bot.menuDicts.Row(btnSync), bot.menuDicts.Row(btnFullSync), bot.menuDicts.Row(btnBack), ) bot.menuBalance = &tele.ReplyMarkup{} // Кнопки пополнения теперь создаются динамически в renderBalanceMenu } func (bot *Bot) initHandlers() { bot.b.Use(middleware.Logger()) bot.b.Use(bot.registrationMiddleware) bot.b.Handle("/start", bot.handleStartCommand) bot.b.Handle("/admin", bot.handleAdminCommand) bot.b.Handle("/draft", bot.draftEditor.handleDraftCommand) bot.b.Handle("/newdraft", bot.draftEditor.handleNewDraftCommand) 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) 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_full_sync"}, bot.triggerFullSync) 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: "adm_list_servers"}, bot.adminListServers) bot.b.Handle(tele.OnCallback, bot.handleCallback) bot.b.Handle(tele.OnText, bot.handleText) bot.b.Handle(tele.OnPhoto, bot.handlePhoto) bot.b.Handle(tele.OnDocument, bot.handleDocument) bot.b.Handle(&tele.Btn{Unique: "noop"}, func(c tele.Context) error { return c.Respond() }) } 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) } } func (bot *Bot) handleStartCommand(c tele.Context) error { payload := c.Message().Payload if payload != "" && strings.HasPrefix(payload, "invite_") { return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_")) } userID := c.Sender().ID if bot.maintenanceMode && !bot.isDev(userID) { return c.Send("🛠 **Сервис на техническом обслуживании** Мы проводим плановые работы... 📸 **Вы можете отправить фото чека или накладной** прямо в этот чат — наша команда обработает его и добавит в систему вручную.", tele.ModeHTML) } welcomeTxt := "🚀 RMSer — ваш умный ассистент для iiko\n\n" + "Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" + "Почему это удобно:\n" + "🧠 Самообучение: Сопоставьте товар один раз, и в следующий раз я узнаю его сам.\n" + "⚙️ Гибкая настройка: Укажите склад по умолчанию и ограничьте область поиска товаров только нужными категориями.\n" + "👥 Работа в команде: Приглашайте сотрудников, распределяйте роли и управляйте доступом прямо в Mini App.\n\n" + "🎁 Старт без риска: Дарим 10 накладных на 30 дней каждому новому серверу!" return bot.renderMainMenuWithText(c, welcomeTxt) } // Вспомогательный метод для рендера (чтобы не дублировать код меню) func (bot *Bot) renderMainMenuWithText(c tele.Context, text string) error { bot.fsm.Reset(c.Sender().ID) userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID) menu := &tele.ReplyMarkup{} btnServers := menu.Data("🖥 Серверы", "nav_servers") btnDicts := menu.Data("🔄 Справочники", "nav_dicts") btnBalance := menu.Data("💰 Баланс", "nav_balance") var rows []tele.Row rows = append(rows, menu.Row(btnServers, btnDicts)) rows = append(rows, menu.Row(btnBalance)) if activeServer != nil { role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID) if role == account.RoleOwner || role == account.RoleAdmin { btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL}) rows = append(rows, menu.Row(btnApp)) } } menu.Inline(rows...) return c.EditOrSend(text, menu, tele.ModeHTML) } func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error { serverID, err := uuid.Parse(serverIDStr) if err != nil { return c.Send("❌ Некорректная ссылка приглашения.") } newUser := c.Sender() userDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName) err = bot.accountRepo.AddUserToServer(serverID, userDB.ID, account.RoleOperator) if err != nil { return c.Send(fmt.Sprintf("❌ Не удалось подключиться к серверу: %v", err)) } bot.rmsFactory.ClearCacheForUser(userDB.ID) activeServer, err := bot.accountRepo.GetActiveServer(userDB.ID) if err != nil || activeServer == nil || activeServer.ID != serverID { return c.Send("✅ Доступ предоставлен, но сервер не стал активным автоматически. Выберите его в меню.") } role, _ := bot.accountRepo.GetUserRole(userDB.ID, serverID) c.Send(fmt.Sprintf("✅ Вы подключены к серверу %s.\nВаша роль: %s.\nТеперь вы можете загружать чеки.", activeServer.Name, role), tele.ModeHTML) if role != account.RoleOwner { go func() { users, err := bot.accountRepo.GetServerUsers(serverID) if err == nil { for _, u := range users { if u.Role == account.RoleOwner { if u.UserID == userDB.ID { continue } name := newUser.FirstName if newUser.LastName != "" { name += " " + newUser.LastName } if newUser.Username != "" { name += fmt.Sprintf(" (@%s)", newUser.Username) } msg := fmt.Sprintf("🔔 Обновление команды\n\nПользователь %s активировал приглашение на сервер «%s» (Роль: %s).", name, activeServer.Name, role) bot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML) break } } } }() } return bot.renderMainMenu(c) } func (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) { msg := fmt.Sprintf("ℹ️ Изменение прав доступа\n\nСервер: %s\nВаша новая роль: %s", serverName, newRole) bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML) } func (bot *Bot) SendRemovalNotification(telegramID int64, serverName string) { msg := fmt.Sprintf("⛔ Доступ закрыт\n\nВы были отключены от сервера %s.", serverName) bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML) } func (bot *Bot) handleAdminCommand(c tele.Context) error { userID := c.Sender().ID if _, isAdmin := bot.adminIDs[userID]; !isAdmin { return nil } menu := &tele.ReplyMarkup{} btnServers := menu.Data("🏢 Список серверов", "adm_list_servers") menu.Inline(menu.Row(btnServers)) return c.Send("🕵️‍♂️ Super Admin Panel\n\nВыберите действие:", menu, tele.ModeHTML) } func (bot *Bot) adminListServers(c tele.Context) error { servers, err := bot.accountRepo.GetAllServersSystemWide() if err != nil { return c.Send("Error: " + err.Error()) } menu := &tele.ReplyMarkup{} var rows []tele.Row for _, s := range servers { btn := menu.Data(fmt.Sprintf("🖥 %s", s.Name), "adm_srv_"+s.ID.String()) rows = append(rows, menu.Row(btn)) } menu.Inline(rows...) return c.EditOrSend("Все серверы системы:", menu, tele.ModeHTML) } func (bot *Bot) renderMainMenu(c tele.Context) error { bot.fsm.Reset(c.Sender().ID) userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID) menu := &tele.ReplyMarkup{} btnServers := menu.Data("🖥 Серверы", "nav_servers") btnDicts := menu.Data("🔄 Справочники", "nav_dicts") btnBalance := menu.Data("💰 Баланс", "nav_balance") var rows []tele.Row rows = append(rows, menu.Row(btnServers, btnDicts)) rows = append(rows, menu.Row(btnBalance)) showApp := false if activeServer != nil { role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID) if role == account.RoleOwner || role == account.RoleAdmin { showApp = true } } if showApp { btnApp := menu.WebApp("📱 Открыть приложение", &tele.WebApp{URL: bot.webAppURL}) rows = append(rows, menu.Row(btnApp)) } menu.Inline(rows...) txt := "👋 Панель управления RMSER\n\n" + "Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников." if activeServer != nil { role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID) txt += fmt.Sprintf("\n\nАктивный сервер: %s (%s)", activeServer.Name, role) } return c.EditOrSend(txt, menu, tele.ModeHTML) } func (bot *Bot) renderServersMenu(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) servers, err := bot.accountRepo.GetAllAvailableServers(userDB.ID) if err != nil { return c.Send("Ошибка БД: " + err.Error()) } activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID) menu := &tele.ReplyMarkup{} var rows []tele.Row for _, s := range servers { icon := "🔴" if activeServer != nil && activeServer.ID == s.ID { icon = "🟢" } role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID) label := fmt.Sprintf("%s %s (%s)", icon, s.Name, role) btn := menu.Data(label, "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 { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID) txt := "💰 Баланс и Тарифы\n\n" if activeServer == nil { txt += "❌ У вас нет активного сервера. Сначала подключите сервер в меню «Серверы»." } else { paidUntil := "не активно" if activeServer.PaidUntil != nil { paidUntil = activeServer.PaidUntil.Format("02.01.2006") } txt += fmt.Sprintf("🏢 Сервер: %s\n", activeServer.Name) txt += fmt.Sprintf("📄 Остаток накладных: %d шт.\n", activeServer.Balance) txt += fmt.Sprintf("📅 Доступен до: %s\n\n", paidUntil) txt += "Выберите способ пополнения:" } menu := &tele.ReplyMarkup{} var rows []tele.Row if activeServer != nil { btnTopUp := menu.Data("💳 Пополнить баланс", "bill_topup") btnGift := menu.Data("🎁 Подарок другу", "bill_gift") rows = append(rows, menu.Row(btnTopUp, btnGift)) } btnBack := menu.Data("🔙 Назад", "nav_main") rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) return c.EditOrSend(txt, menu, tele.ModeHTML) } func (bot *Bot) renderTariffShowcase(c tele.Context, targetURL string) error { tariffs := bot.billingService.GetTariffs() menu := &tele.ReplyMarkup{} txt := "🛒 Выберите тарифный план\n" if targetURL != "" { txt += fmt.Sprintf("🎁 Оформление подарка для сервера: %s\n", targetURL) } txt += "\nПакеты (разово):" var rows []tele.Row for _, t := range tariffs { label := fmt.Sprintf("%s — %.0f₽ (%d шт)", t.Name, t.Price, t.InvoicesCount) btn := menu.Data(label, "buy_id_"+t.ID) rows = append(rows, menu.Row(btn)) } btnBack := menu.Data("🔙 Отмена", "nav_balance") rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) return c.EditOrSend(txt, menu, tele.ModeHTML) } func (bot *Bot) handleCallback(c tele.Context) error { data := c.Callback().Data if len(data) > 0 && data[0] == '\f' { data = data[1:] } userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) userID := c.Sender().ID if bot.maintenanceMode && !bot.isDev(userID) { return c.Respond(&tele.CallbackResponse{Text: "Сервис на обслуживании"}) } // === DRAFT EDITOR CALLBACKS === // Проверяем префиксы редактора черновиков draftPrefixes := []string{"di:", "dp:", "da:", "dc:", "dx:", "den:", "deq:", "dep:", "did:", "dib:"} for _, prefix := range draftPrefixes { if strings.HasPrefix(data, prefix) { return bot.draftEditor.handleDraftEditorCallback(c, data) } } // --- INTEGRATION: Billing Callbacks --- if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") { return bot.handleBillingCallbacks(c, data, userDB) } 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] } 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: "Ошибка: доступ запрещен"}) } bot.rmsFactory.ClearCacheForUser(userDB.ID) 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) role, err := bot.accountRepo.GetUserRole(userDB.ID, targetID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка прав доступа"}) } if role == account.RoleOwner { if err := bot.accountRepo.DeleteServer(targetID); err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"}) } bot.rmsFactory.ClearCacheForUser(userDB.ID) c.Respond(&tele.CallbackResponse{Text: "Сервер полностью удален"}) } else { if err := bot.accountRepo.RemoveUserFromServer(targetID, userDB.ID); err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка выхода"}) } bot.rmsFactory.ClearCacheForUser(userDB.ID) c.Respond(&tele.CallbackResponse{Text: "Вы покинули сервер"}) } active, _ := bot.accountRepo.GetActiveServer(userDB.ID) if active == nil { all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID) if len(all) > 0 { _ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID) } } return bot.renderDeleteServerMenu(c) } if strings.HasPrefix(data, "gen_invite_") { serverIDStr := strings.TrimPrefix(data, "gen_invite_") link := fmt.Sprintf("https://t.me/%s?start=invite_%s", bot.b.Me.Username, serverIDStr) c.Respond() return c.Send(fmt.Sprintf("🔗 Ссылка для приглашения:\n\n%s\n\nОтправьте её сотруднику.", link), tele.ModeHTML) } if strings.HasPrefix(data, "adm_srv_") { serverIDStr := strings.TrimPrefix(data, "adm_srv_") serverID := parseUUID(serverIDStr) return bot.renderServerUsers(c, serverID) } if strings.HasPrefix(data, "adm_usr_") { connIDStr := strings.TrimPrefix(data, "adm_usr_") connID := parseUUID(connIDStr) link, err := bot.accountRepo.GetConnectionByID(connID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"}) } if link.Role == account.RoleOwner { return c.Respond(&tele.CallbackResponse{Text: "Этот пользователь уже Владелец"}) } menu := &tele.ReplyMarkup{} btnYes := menu.Data("✅ Сделать Владельцем", fmt.Sprintf("adm_own_yes_%s", link.ID.String())) btnNo := menu.Data("Отмена", "adm_srv_"+link.ServerID.String()) menu.Inline(menu.Row(btnYes), menu.Row(btnNo)) txt := fmt.Sprintf("⚠️ Внимание!\n\nВы собираетесь передать права Владельца сервера %s пользователю %s.\n\nТекущий владелец станет Администратором.", link.Server.Name, link.User.FirstName) return c.EditOrSend(txt, menu, tele.ModeHTML) } if strings.HasPrefix(data, "adm_own_yes_") { connIDStr := strings.TrimPrefix(data, "adm_own_yes_") connID := parseUUID(connIDStr) link, err := bot.accountRepo.GetConnectionByID(connID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка: связь не найдена"}) } if err := bot.accountRepo.TransferOwnership(link.ServerID, link.UserID); err != nil { logger.Log.Error("Ownership transfer failed", zap.Error(err)) return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()}) } go func() { msg := fmt.Sprintf("👑 Поздравляем!\n\nВам переданы права Владельца (OWNER) сервера %s.", link.Server.Name) bot.b.Send(&tele.User{ID: link.User.TelegramID}, msg, tele.ModeHTML) }() c.Respond(&tele.CallbackResponse{Text: "Успешно!"}) return bot.renderServerUsers(c, link.ServerID) } return nil } // Реализация метода интерфейса PaymentNotifier func (bot *Bot) NotifySuccess(userID uuid.UUID, amount float64, newBalance int, serverName string) { user, err := bot.accountRepo.GetUserByID(userID) if err != nil { logger.Log.Error("Failed to find user for payment notification", zap.Error(err)) return } msg := fmt.Sprintf( "✅ Оплата получена!\n\n"+ "Сумма: %.2f ₽\n"+ "Сервер: %s\n"+ "Текущий баланс: %d накладных\n\n"+ "Спасибо за использование RMSer!", amount, serverName, newBalance, ) bot.b.Send(&tele.User{ID: user.TelegramID}, msg, tele.ModeHTML) } func (bot *Bot) triggerFullSync(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) server, _ := bot.accountRepo.GetActiveServer(userDB.ID) if server == nil { return c.Respond(&tele.CallbackResponse{Text: "Нет активного сервера"}) } c.Respond(&tele.CallbackResponse{Text: "Запущена полная перезагрузка данных...", ShowAlert: false}) c.Send("⏳ Полная синхронизация\\nОбновляю историю накладных за 60 дней и справочники. Это может занять до 1 минуты.", tele.ModeHTML) go func() { if err := bot.syncService.SyncAllData(userDB.ID, true); err != nil { bot.b.Send(c.Sender(), "❌ Ошибка при полной синхронизации: "+err.Error()) } else { bot.b.Send(c.Sender(), "✅ Все данные успешно обновлены!") } }() return nil } func (bot *Bot) handleBillingCallbacks(c tele.Context, data string, userDB *account.User) error { if data == "bill_topup" { return bot.renderTariffShowcase(c, "") } if data == "bill_gift" { bot.fsm.SetState(c.Sender().ID, StateBillingGiftURL) return c.EditOrSend("🎁 Режим подарка\n\nВведите URL сервера, который хотите пополнить (например: https://myresto.iiko.it).\n\nСервер должен быть уже зарегистрирован в нашей системе.", tele.ModeHTML) } if strings.HasPrefix(data, "pay_confirm_") { orderIDStr := strings.TrimPrefix(data, "pay_confirm_") orderID, _ := uuid.Parse(orderIDStr) if err := bot.billingService.ConfirmOrder(orderID); err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка: " + err.Error()}) } c.Respond(&tele.CallbackResponse{Text: "✨ Оплата успешно имитирована!"}) bot.fsm.Reset(c.Sender().ID) return c.EditOrSend("✅ Оплата прошла успешно!\n\nУслуги начислены на баланс сервера. Теперь вы можете продолжить работу с накладными.", bot.menuBalance, tele.ModeHTML) } if strings.HasPrefix(data, "buy_id_") { tariffID := strings.TrimPrefix(data, "buy_id_") ctxFSM := bot.fsm.GetContext(c.Sender().ID) // 1. Формируем URL возврата (ссылка на бота) returnURL := fmt.Sprintf("https://t.me/%s", bot.b.Me.Username) // 2. Вызываем обновленный метод (теперь возвращает 3 значения) order, payURL, err := bot.billingService.CreateOrder( context.Background(), userDB.ID, tariffID, ctxFSM.BillingTargetURL, returnURL, ) if err != nil { return c.Send("❌ Ошибка при формировании счета: " + err.Error()) } // 3. Создаем кнопку с URL на ЮКассу menu := &tele.ReplyMarkup{} btnPay := menu.URL("💳 Оплатить", payURL) // payURL теперь string btnBack := menu.Data("🔙 Отмена", "nav_balance") menu.Inline(menu.Row(btnPay), menu.Row(btnBack)) txt := fmt.Sprintf( "📦 Заказ №%s\n\nСумма к оплате: %.2f ₽\n\nНажмите кнопку ниже для перехода к оплате. Баланс будет пополнен автоматически сразу после подтверждения платежа.", order.ID.String()[:8], // Показываем короткий ID для красоты order.Amount, ) return c.EditOrSend(txt, menu, tele.ModeHTML) } return nil } func (bot *Bot) renderServerUsers(c tele.Context, serverID uuid.UUID) error { users, err := bot.accountRepo.GetServerUsers(serverID) if err != nil { return c.Respond(&tele.CallbackResponse{Text: "Ошибка загрузки юзеров"}) } menu := &tele.ReplyMarkup{} var rows []tele.Row for _, u := range users { roleIcon := "👤" if u.Role == account.RoleOwner { roleIcon = "👑" } if u.Role == account.RoleAdmin { roleIcon = "⭐️" } label := fmt.Sprintf("%s %s %s", roleIcon, u.User.FirstName, u.User.LastName) payload := fmt.Sprintf("adm_usr_%s", u.ID.String()) btn := menu.Data(label, payload) rows = append(rows, menu.Row(btn)) } btnBack := menu.Data("🔙 К серверам", "adm_list_servers") rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) serverName := "Unknown" if len(users) > 0 { serverName = users[0].Server.Name } return c.EditOrSend(fmt.Sprintf("👥 Пользователи сервера %s:", serverName), menu, tele.ModeHTML) } func (bot *Bot) triggerSync(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) server, err := bot.accountRepo.GetActiveServer(userDB.ID) if err != nil || server == nil { return c.Respond(&tele.CallbackResponse{Text: "Нет активного сервера"}) } role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID) if role == account.RoleOperator { return c.Respond(&tele.CallbackResponse{Text: "⚠️ Синхронизация доступна только Админам", ShowAlert: true}) } c.Respond(&tele.CallbackResponse{Text: "Запускаю синхронизацию..."}) go func() { if err := bot.syncService.SyncAllData(userDB.ID, false); err != nil { logger.Log.Error("Manual sync failed", zap.Error(err)) bot.b.Send(c.Sender(), "❌ Ошибка синхронизации. Проверьте настройки сервера.") } else { bot.b.Send(c.Sender(), "✅ Синхронизация успешно завершена!") } }() return nil } func (bot *Bot) startAddServerFlow(c tele.Context) error { bot.fsm.SetState(c.Sender().ID, StateAddServerURL) return c.EditOrSend("🔗 Введите URL вашего сервера iikoRMS.\nПример: https://resto.iiko.it\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 bot.maintenanceMode && !bot.isDev(userID) { return c.Send("Сервис на обслуживании", tele.ModeHTML) } if strings.ToLower(text) == "отмена" || strings.ToLower(text) == "/cancel" { bot.fsm.Reset(userID) return bot.renderMainMenu(c) } // === DRAFT EDITOR TEXT HANDLERS === // Проверяем, находимся ли мы в одном из состояний редактирования черновика if state == StateDraftEditItemName || state == StateDraftEditItemQty || state == StateDraftEditItemPrice { return bot.draftEditor.handleDraftEditorText(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(), "⏳ Проверяю подключение...") 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)) } 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) case StateBillingGiftURL: if !strings.HasPrefix(text, "http") { return c.Send("❌ Некорректный URL. Он должен начинаться с http:// или https://") } _, err := bot.accountRepo.GetServerByURL(text) if err != nil { return c.Send("🔍 Сервер с таким URL не найден в системе. Попросите владельца сначала подключить его к боту.") } bot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) { ctx.BillingTargetURL = text }) return bot.renderTariffShowcase(c, text) } 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("Ошибка базы данных пользователей") } userID := c.Sender().ID _, err = bot.rmsFactory.GetClientForUser(userDB.ID) if err != nil { return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.") } 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("⏳ ИИ анализирует документ...\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() draft, err := bot.ocrService.ProcessDocument(ctx, userDB.ID, imgData, "photo.jpg") 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++ } } // Для разработчиков отправляем debug-информацию if bot.isDev(userID) { 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("✅ Все позиции распознаны!\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items)) } else { msgText = fmt.Sprintf("⚠️ Распознано позиций: %d из %d\n\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items)) } menu := &tele.ReplyMarkup{} role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID) if role != account.RoleOperator { btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL}) menu.Inline(menu.Row(btnOpen)) } else { msgText += "\n\n(Редактирование доступно Администратору)" } c.Send(msgText, menu, tele.ModeHTML) } // Инициализируем FSM редактора для всех пользователей bot.fsm.InitDraftEditor(userID, draft.ID) // Показываем меню редактора return bot.draftEditor.renderDraftEditorMenu(c, draft, 0) } func (bot *Bot) handleDocument(c tele.Context) error { userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "") if err != nil { return c.Send("Ошибка базы данных пользователей") } userID := c.Sender().ID _, err = bot.rmsFactory.GetClientForUser(userDB.ID) if err != nil { return c.Send("⛔ Ошибка доступа к iiko или сервер не выбран.\nПроверьте статус сервера в меню.") } doc := c.Message().Document filename := doc.FileName // Проверяем расширение файла ext := strings.ToLower(filepath.Ext(filename)) allowedExtensions := map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".xlsx": true, } if !allowedExtensions[ext] { return c.Send("❌ Неподдерживаемый формат файла. Пожалуйста, отправьте изображение (.jpg, .jpeg, .png) или Excel файл (.xlsx).") } file, err := bot.b.FileByID(doc.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() fileData, err := io.ReadAll(resp.Body) if err != nil { return c.Send("Ошибка чтения файла.") } c.Send("⏳ ИИ анализирует документ...\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML) ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() draft, err := bot.ocrService.ProcessDocument(ctx, userDB.ID, fileData, filename) 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++ } } // Для разработчиков отправляем debug-информацию if bot.isDev(userID) { 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("✅ Все позиции распознаны!\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items)) } else { msgText = fmt.Sprintf("⚠️ Распознано позиций: %d из %d\n\nОстальные товары я вижу впервые. Воспользуйтесь удобным интерфейсом сопоставления в приложении — я запомню ваш выбор навсегда.", matchedCount, len(draft.Items)) } menu := &tele.ReplyMarkup{} role, _ := bot.accountRepo.GetUserRole(userDB.ID, draft.RMSServerID) if role != account.RoleOperator { btnOpen := menu.WebApp("📝 Открыть и сопоставить", &tele.WebApp{URL: fullURL}) menu.Inline(menu.Row(btnOpen)) } else { msgText += "\n\n(Редактирование доступно Администратору)" } c.Send(msgText, menu, tele.ModeHTML) } // Инициализируем FSM редактора для всех пользователей bot.fsm.InitDraftEditor(userID, draft.ID) // Показываем меню редактора return bot.draftEditor.renderDraftEditorMenu(c, draft, 0) } 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) userDB, _ := bot.accountRepo.GetOrCreateUser(userID, c.Sender().Username, "", "") encPass, _ := bot.cryptoManager.Encrypt(ctx.TempPassword) server, err := bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, serverName) if err != nil { return c.Send("❌ Ошибка подключения сервера: " + err.Error()) } bot.fsm.Reset(userID) role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID) successMsg := fmt.Sprintf("✅ Сервер %s успешно подключен!\nВаша роль: %s\n\n", server.Name, role) // Проверяем, новый ли это сервер по балансу и дате создания (упрощенно для уведомления) if server.Balance == 10 { successMsg += "🎁 Вам начислен приветственный бонус: 10 накладных на 30 дней! Пользуйтесь с удовольствием.\n\n" } successMsg += "Начинаю первичную синхронизацию данных..." c.Send(successMsg, tele.ModeHTML) go bot.syncService.SyncAllData(userDB.ID, false) return bot.renderMainMenu(c) } func (bot *Bot) renderDeleteServerMenu(c tele.Context) error { userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID) servers, err := bot.accountRepo.GetAllAvailableServers(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 { role, _ := bot.accountRepo.GetUserRole(userDB.ID, s.ID) var label string if role == account.RoleOwner { label = fmt.Sprintf("❌ Удалить %s (Owner)", s.Name) } else { label = fmt.Sprintf("🚪 Покинуть %s", s.Name) } btnAction := menu.Data(label, "do_del_server_"+s.ID.String()) if role == account.RoleOwner || role == account.RoleAdmin { btnInvite := menu.Data(fmt.Sprintf("📩 Invite %s", s.Name), "gen_invite_"+s.ID.String()) rows = append(rows, menu.Row(btnAction, btnInvite)) } else { rows = append(rows, menu.Row(btnAction)) } } btnBack := menu.Data("🔙 Назад к списку", "nav_servers") rows = append(rows, menu.Row(btnBack)) menu.Inline(rows...) return c.EditOrSend("⚙️ Управление серверами\n\nЗдесь вы можете удалить сервер или пригласить сотрудников.", menu, tele.ModeHTML) } // NotifyDevs отправляет фото разработчикам для отладки func (bot *Bot) NotifyDevs(devIDs []int64, photoPath string, serverName string, serverID string) { // Формируем подпись для фото caption := fmt.Sprintf("🛠 **Debug Capture**\nServer: %s (`%s`)\nFile: %s", serverName, serverID, photoPath) // В цикле отправляем фото каждому разработчику for _, id := range devIDs { photo := &tele.Photo{ File: tele.FromDisk(photoPath), Caption: caption, } // Отправляем фото пользователю _, err := bot.b.Send(&tele.User{ID: id}, photo) if err != nil { logger.Log.Error("Failed to send debug photo", zap.Int64("userID", id), zap.Error(err)) } } } func parseUUID(s string) uuid.UUID { id, _ := uuid.Parse(s) return id }