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)
}