// 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/auth"
"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
authService *auth.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,
authService *auth.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,
authService: authService,
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
// Обработка desktop авторизации
if payload != "" && strings.HasPrefix(payload, "auth_") {
sessionID := strings.TrimPrefix(payload, "auth_")
telegramID := c.Sender().ID
logger.Log.Info("Обработка desktop авторизации",
zap.String("session_id", sessionID),
zap.Int64("telegram_id", telegramID),
)
if err := bot.authService.ConfirmDesktopAuth(sessionID, telegramID); err != nil {
logger.Log.Error("Ошибка подтверждения desktop авторизации",
zap.String("session_id", sessionID),
zap.Int64("telegram_id", telegramID),
zap.Error(err),
)
return c.Send("❌ Ошибка авторизации. Попробуйте снова.", tele.ModeHTML)
}
return c.Send("✅ Авторизация успешна! Вы можете вернуться в приложение.", tele.ModeHTML)
}
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, "srv_menu_"+s.ID.String())
rows = append(rows, menu.Row(btn))
}
btnAdd := menu.Data("➕ Добавить сервер", "act_add_server")
btnBack := menu.Data("🔙 Назад", "nav_main")
rows = append(rows, menu.Row(btnAdd))
rows = append(rows, menu.Row(btnBack))
menu.Inline(rows...)
txt := fmt.Sprintf("🖥 Ваши серверы (%d):\n\nНажмите на сервер для управления.", len(servers))
return c.EditOrSend(txt, menu, tele.ModeHTML)
}
// renderServerMenu показывает подменю управления конкретным сервером
func (bot *Bot) renderServerMenu(c tele.Context, serverID uuid.UUID) error {
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
server, err := bot.accountRepo.GetServerByID(serverID)
if err != nil {
return c.Send("Ошибка: сервер не найден")
}
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
isActive := activeServer != nil && activeServer.ID == server.ID
menu := &tele.ReplyMarkup{}
var rows []tele.Row
// Кнопка "Выбрать активным" (доступна всем)
if !isActive {
btnSetActive := menu.Data("✅ Выбрать активным", "srv_set_active_"+server.ID.String())
rows = append(rows, menu.Row(btnSetActive))
} else {
btnActive := menu.Data("🟢 Активный сервер", "noop")
rows = append(rows, menu.Row(btnActive))
}
// Кнопка "Показать URL/Логин" (доступна Admin и Owner)
if role == account.RoleOwner || role == account.RoleAdmin {
btnShowCreds := menu.Data("👁 Показать URL/Логин", "srv_show_creds_"+server.ID.String())
btnInvite := menu.Data("📩 Пригласить сотрудника", fmt.Sprintf("gen_invite_%s", server.ID.String()))
rows = append(rows, menu.Row(btnShowCreds, btnInvite))
}
// Кнопка "Обновить логин-пароль" (только Owner)
if role == account.RoleOwner {
btnUpdateCreds := menu.Data("✏️ Обновить логин-пароль", "srv_update_creds_"+server.ID.String())
rows = append(rows, menu.Row(btnUpdateCreds))
}
// Кнопка "Удалить сервер" (только Owner)
if role == account.RoleOwner {
btnDelete := menu.Data("❌ Удалить сервер", "srv_delete_"+server.ID.String())
rows = append(rows, menu.Row(btnDelete))
}
btnBack := menu.Data("🔙 Назад к списку", "nav_servers")
rows = append(rows, menu.Row(btnBack))
menu.Inline(rows...)
txt := fmt.Sprintf("⚙️ Управление сервером\n\n🏢 Название: %s\n🔗 URL: %s\n👤 Ваша роль: %s",
server.Name, server.BaseURL, role)
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, "srv_menu_") {
serverIDStr := strings.TrimPrefix(data, "srv_menu_")
serverIDStr = strings.TrimSpace(serverIDStr)
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx]
}
targetID := parseUUID(serverIDStr)
return bot.renderServerMenu(c, targetID)
}
if strings.HasPrefix(data, "srv_set_active_") {
serverIDStr := strings.TrimPrefix(data, "srv_set_active_")
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.renderServerMenu(c, targetID)
}
if strings.HasPrefix(data, "srv_show_creds_") {
serverIDStr := strings.TrimPrefix(data, "srv_show_creds_")
serverIDStr = strings.TrimSpace(serverIDStr)
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx]
}
targetID := parseUUID(serverIDStr)
server, err := bot.accountRepo.GetServerByID(targetID)
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: сервер не найден"})
}
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
if role != account.RoleOwner && role != account.RoleAdmin {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: недостаточно прав"})
}
// Получаем личные креды пользователя через GetServerUsers
serverUsers, err := bot.accountRepo.GetServerUsers(server.ID)
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка получения данных"})
}
var login string
for _, su := range serverUsers {
if su.UserID == userDB.ID {
login = su.Login
break
}
}
if login == "" {
return c.Respond(&tele.CallbackResponse{Text: "У вас нет сохраненных учетных данных"})
}
c.Respond()
return c.Send(fmt.Sprintf("🔑 Учетные данные сервера\n\n🏢 Название: %s\n🔗 URL: %s\n👤 Логин: %s\n🔒 Пароль: ***скрыт***",
server.Name, server.BaseURL, login), tele.ModeHTML)
}
if strings.HasPrefix(data, "srv_update_creds_") {
serverIDStr := strings.TrimPrefix(data, "srv_update_creds_")
serverIDStr = strings.TrimSpace(serverIDStr)
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx]
}
targetID := parseUUID(serverIDStr)
server, err := bot.accountRepo.GetServerByID(targetID)
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: сервер не найден"})
}
role, _ := bot.accountRepo.GetUserRole(userDB.ID, server.ID)
if role != account.RoleOwner {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: только владелец может обновлять учетные данные"})
}
// Сохраняем ID сервера в контексте FSM
bot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {
ctx.EditingServerID = server.ID
ctx.TempURL = server.BaseURL
})
bot.fsm.SetState(c.Sender().ID, StateUpdateServerLogin)
c.Respond()
return c.EditOrSend("✏️ Обновление учетных данных\n\nВведите новый логин для сервера "+server.Name+".\n\n(Напишите 'отмена' для выхода)", tele.ModeHTML)
}
if strings.HasPrefix(data, "srv_delete_") {
serverIDStr := strings.TrimPrefix(data, "srv_delete_")
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 {
return c.Respond(&tele.CallbackResponse{Text: "Только владелец может удалить сервер"})
}
// Подтверждение удаления
menu := &tele.ReplyMarkup{}
btnYes := menu.Data("✅ Да, удалить", "srv_delete_confirm_"+targetID.String())
btnNo := menu.Data("❌ Отмена", "srv_menu_"+targetID.String())
menu.Inline(menu.Row(btnYes), menu.Row(btnNo))
server, _ := bot.accountRepo.GetServerByID(targetID)
return c.EditOrSend("⚠️ Подтверждение удаления\n\nВы уверены, что хотите удалить сервер "+server.Name+"?\n\nЭто действие необратимо!", menu, tele.ModeHTML)
}
if strings.HasPrefix(data, "srv_delete_confirm_") {
serverIDStr := strings.TrimPrefix(data, "srv_delete_confirm_")
serverIDStr = strings.TrimSpace(serverIDStr)
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx]
}
targetID := parseUUID(serverIDStr)
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
}
bot.rmsFactory.ClearCacheForUser(userDB.ID)
c.Respond(&tele.CallbackResponse{Text: "Сервер удален"})
return bot.renderServersMenu(c)
}
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())
userDB, _ := bot.accountRepo.GetUserByTelegramID(userID)
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)
case StateUpdateServerLogin:
ctx := bot.fsm.GetContext(userID)
if ctx.EditingServerID == uuid.Nil {
bot.fsm.Reset(userID)
return bot.renderMainMenu(c)
}
bot.fsm.UpdateContext(userID, func(uCtx *UserContext) {
uCtx.TempLogin = text
uCtx.State = StateUpdateServerPassword
})
return c.Send("🔑 Введите новый пароль:")
case StateUpdateServerPassword:
password := text
ctx := bot.fsm.GetContext(userID)
if ctx.EditingServerID == uuid.Nil {
bot.fsm.Reset(userID)
return bot.renderMainMenu(c)
}
server, err := bot.accountRepo.GetServerByID(ctx.EditingServerID)
if err != nil {
bot.fsm.Reset(userID)
return c.Send("❌ Ошибка: сервер не найден")
}
// Проверяем новые креды
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)
bot.fsm.Reset(userID)
return c.Send(fmt.Sprintf("❌ Ошибка авторизации: %v\nПроверьте логин/пароль.", err))
}
// Шифруем пароль и сохраняем
encPass, _ := bot.cryptoManager.Encrypt(password)
// Обновляем креды через ConnectServer (он обновит существующую связь)
_, err = bot.accountRepo.ConnectServer(userDB.ID, ctx.TempURL, ctx.TempLogin, encPass, server.Name)
bot.b.Delete(msg)
if err != nil {
bot.fsm.Reset(userID)
return c.Send("❌ Ошибка обновления данных")
}
bot.fsm.Reset(userID)
bot.rmsFactory.ClearCacheForUser(userDB.ID)
c.Send("✅ Учетные данные обновлены!\n\nТеперь вы можете использовать новые логин и пароль для подключения к серверу.", tele.ModeHTML)
return bot.renderServerMenu(c, server.ID)
}
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
}