Files
rmser/internal/transport/telegram/bot.go
SERTY 4e4571b3db Настройки работают
Иерархия групп работает
Полностью завязано на пользователя и серверы
2025-12-18 07:21:31 +03:00

628 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 := "👋 <b>Панель управления RMSER</b>\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_<UUID>"
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("<b>🖥 Ваши серверы (%d):</b>\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("⚠️ <b>Статус:</b> Ошибка (%v)", err)
} else {
lastUpdate := "—"
if stats.LastInvoice != nil {
lastUpdate = stats.LastInvoice.Format("02.01.2006")
}
txt = fmt.Sprintf("<b>🔄 Состояние справочников</b>\n\n"+
"🏢 <b>Сервер:</b> %s\n"+
"📦 <b>Товары:</b> %d\n"+
"🚚 <b>Поставщики:</b> %d\n"+
"🏭 <b>Склады:</b> %d\n\n"+
"📄 <b>Накладные (30дн):</b> %d\n"+
"📅 <b>Посл. документ:</b> %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 := "<b>💰 Ваш баланс</b>\n\n" +
"💵 Текущий счет: <b>0.00 ₽</b>\n" +
"💎 Тариф: <b>Free</b>\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("🔗 Введите <b>URL</b> вашего сервера iikoRMS.\nПример: <code>https://iiko.myrest.ru:443</code>\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("🔎 Обнаружено имя сервера: <b>%s</b>.\nИспользовать его?", detectedName), menu, tele.ModeHTML)
}
// Если имя не нашли - просим ввести вручную
bot.fsm.SetState(userID, StateAddServerInputName)
return c.Send("🏷 Введите <b>название</b> для этого сервера (для вашего удобства):")
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("✅ <b>Успех!</b> Все позиции (%d) распознаны.\n\nПереходите к созданию накладной.", len(draft.Items))
} else {
msgText = fmt.Sprintf("⚠️ <b>Внимание!</b> Распознано %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("🏷 Хорошо, введите желаемое <b>название</b>:")
}
// 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("✅ Сервер <b>%s</b> успешно добавлен!", 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("🗑 <b>Удаление сервера</b>\n\nНажмите на сервер, который хотите удалить.\nЭто действие нельзя отменить.", menu, tele.ModeHTML)
}