Files
rmser/internal/transport/telegram/bot.go
SERTY b4ce819931 добавил пользователей для сервера и роли
добавил инвайт-ссылки с ролью оператор для сервера
добавил супер-админку для смены владельцев
добавил уведомления о смене ролей на серверах
добавил модалку для фото прям в черновике
добавил UI для редактирования прав
2025-12-23 13:06:06 +03:00

878 lines
31 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 удаляем как статическое поле, так как оно теперь динамическое
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() {
// --- 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.handleStartCommand)
// Admin Commands
bot.b.Handle("/admin", bot.handleAdminCommand)
// 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.Btn{Unique: "adm_list_servers"}, bot.adminListServers)
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)
}
}
// handleStartCommand обрабатывает /start и deep linking (приглашения)
func (bot *Bot) handleStartCommand(c tele.Context) error {
payload := c.Message().Payload // То, что после /start <payload>
// Если есть payload, пробуем разобрать как приглашение
if payload != "" && strings.HasPrefix(payload, "invite_") {
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
}
return bot.renderMainMenu(c)
}
// handleInviteLink обрабатывает приглашение пользователя на сервер
func (bot *Bot) handleInviteLink(c tele.Context, serverIDStr string) error {
serverID, err := uuid.Parse(serverIDStr)
if err != nil {
return c.Send("❌ Некорректная ссылка приглашения.")
}
newUser := c.Sender()
// Гарантируем, что юзер есть в БД (хотя middleware это делает, тут для надежности перед логикой)
userDB, _ := bot.accountRepo.GetOrCreateUser(newUser.ID, newUser.Username, newUser.FirstName, newUser.LastName)
// Добавляем пользователя (RoleOperator - желаемая, но репозиторий может оставить более высокую)
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)
// 1. Отправляем сообщение пользователю
c.Send(fmt.Sprintf("✅ Вы подключены к серверу <b>%s</b>.\nВаша роль: <b>%s</b>.\nТеперь вы можете загружать чеки.", activeServer.Name, role), tele.ModeHTML)
// 2. Уведомляем Владельца (только если это реально новый человек или роль изменилась, но упростим - шлем всегда при переходе по ссылке)
// Но не шлем уведомление, если Владелец перешел по своей же ссылке
if role != account.RoleOwner {
go func() {
users, err := bot.accountRepo.GetServerUsers(serverID)
if err == nil {
for _, u := range users {
if u.Role == account.RoleOwner {
// Не уведомляем, если это тот же человек (хотя проверка выше role != Owner уже отсекла это, но на всякий случай)
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("🔔 <b>Обновление команды</b>\n\nПользователь <b>%s</b> активировал приглашение на сервер «%s» (Роль: %s).", name, activeServer.Name, role)
bot.b.Send(&tele.User{ID: u.User.TelegramID}, msg, tele.ModeHTML)
break
}
}
}
}()
}
return bot.renderMainMenu(c)
}
// Реализация интерфейса handlers.Notifier
func (bot *Bot) SendRoleChangeNotification(telegramID int64, serverName string, newRole string) {
msg := fmt.Sprintf(" <b>Изменение прав доступа</b>\n\nСервер: <b>%s</b>\nВаша новая роль: <b>%s</b>", serverName, newRole)
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
}
func (bot *Bot) SendRemovalNotification(telegramID int64, serverName string) {
msg := fmt.Sprintf("⛔ <b>Доступ закрыт</b>\n\nВы были отключены от сервера <b>%s</b>.", serverName)
bot.b.Send(&tele.User{ID: telegramID}, msg, tele.ModeHTML)
}
// handleAdminCommand - точка входа в админку
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("🕵️‍♂️ <b>Super Admin Panel</b>\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 {
// adm_srv_<UUID>
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("<b>Все серверы системы:</b>", menu, tele.ModeHTML)
}
// --- RENDERERS (View Layer) ---
// renderMainMenu строит меню динамически в зависимости от роли
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))
// Проверяем роль для отображения кнопки App
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))
} else {
// Если оператор или нет сервера, можно добавить подсказку или просто ничего
// Для оператора это нормально. Для нового юзера - он пойдет в "Серверы"
}
menu.Inline(rows...)
txt := "👋 <b>Панель управления RMSER</b>\n\n" +
"Здесь вы можете управлять подключенными серверами iiko и следить за актуальностью справочников."
if activeServer != nil {
role, _ := bot.accountRepo.GetUserRole(userDB.ID, activeServer.ID)
txt += fmt.Sprintf("\n\nАктивный сервер: <b>%s</b> (%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("<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
if len(data) > 0 && data[0] == '\f' {
data = data[1:]
}
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
// --- SELECT 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]
}
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: "✅ Сервер выбран"})
// Важно: перерисовываем главное меню, чтобы обновилась кнопка App (появилась/пропала)
// Но мы находимся в подменю. Логичнее остаться в ServersMenu, но кнопка App в MainMenu.
// Пользователь нажмет "Назад" и попадет в MainMenu, где сработает renderMainMenu с новой логикой.
return bot.renderServersMenu(c)
}
// --- DELETE / LEAVE SERVER ---
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)
}
// --- INVITE LINK GENERATION ---
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("🔗 <b>Ссылка для приглашения:</b>\n\n<code>%s</code>\n\nОтправьте её сотруднику.", link), tele.ModeHTML)
}
// --- ADMIN: SELECT SERVER -> SHOW USERS ---
if strings.HasPrefix(data, "adm_srv_") {
serverIDStr := strings.TrimPrefix(data, "adm_srv_")
serverID := parseUUID(serverIDStr)
return bot.renderServerUsers(c, serverID) // <--- ВЫЗОВ НОВОГО МЕТОДА
}
// --- ADMIN: SELECT USER -> CONFIRM OWNERSHIP ---
if strings.HasPrefix(data, "adm_usr_") {
// Получаем ID связи
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{}
// ИСПРАВЛЕНИЕ: Для подтверждения тоже передаем ID связи
// adm_own_yes_ + UUID = 12 + 36 = 48 байт (OK)
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("⚠️ <b>Внимание!</b>\n\nВы собираетесь передать права Владельца сервера <b>%s</b> пользователю <b>%s</b>.\n\nТекущий владелец станет Администратором.",
link.Server.Name, link.User.FirstName)
return c.EditOrSend(txt, menu, tele.ModeHTML)
}
// --- ADMIN: EXECUTE TRANSFER ---
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("👑 <b>Поздравляем!</b>\n\nВам переданы права Владельца (OWNER) сервера <b>%s</b>.", 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
}
// --- Вспомогательный метод рендера списка пользователей ---
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)
// Используем ID связи
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...)
// Для заголовка нам нужно имя сервера, но в users[0].Server оно есть (Preload),
// либо если юзеров нет (пустой сервер?), то имя не узнаем без доп запроса.
// Но пустой сервер вряд ли будет, там как минимум Owner.
serverName := "Unknown"
if len(users) > 0 {
serverName = users[0].Server.Name
}
return c.EditOrSend(fmt.Sprintf("👥 Пользователи сервера <b>%s</b>:", 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); 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://resto.iiko.it</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(), "⏳ Проверяю подключение...")
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("🔎 Обнаружено имя сервера: <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Проверьте статус сервера в меню.")
}
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{}
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<i>(Редактирование доступно Администратору)</i>"
}
return c.Send(msgText, menu, tele.ModeHTML)
}
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>:")
}
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)
c.Send(fmt.Sprintf("✅ Сервер <b>%s</b> подключен!\nВаша роль: <b>%s</b>", server.Name, role), tele.ModeHTML)
if role == account.RoleOwner {
go bot.syncService.SyncAllData(userDB.ID)
} else {
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.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("⚙️ <b>Управление серверами</b>\n\nЗдесь вы можете удалить сервер или пригласить сотрудников.", menu, tele.ModeHTML)
}
func parseUUID(s string) uuid.UUID {
id, _ := uuid.Parse(s)
return id
}