Files
rmser/internal/transport/telegram/bot.go

935 lines
37 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.

// internal/transport/telegram/bot.go
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/billing"
"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
billingService *billing.Service
accountRepo account.Repository
rmsFactory *rms.Factory
cryptoManager *crypto.CryptoManager
fsm *StateManager
adminIDs map[int64]struct{}
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,
) (*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,
billingService: billingService,
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
}
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(&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)
}
func (bot *Bot) Start() {
logger.Log.Info("Запуск Telegram бота...")
bot.b.Start()
}
func (bot *Bot) Stop() { bot.b.Stop() }
func (bot *Bot) registrationMiddleware(next tele.HandlerFunc) tele.HandlerFunc {
return func(c tele.Context) error {
user := c.Sender()
_, err := bot.accountRepo.GetOrCreateUser(user.ID, user.Username, user.FirstName, user.LastName)
if err != nil {
logger.Log.Error("Failed to register user", zap.Error(err))
}
return next(c)
}
}
func (bot *Bot) handleStartCommand(c tele.Context) error {
payload := c.Message().Payload
if payload != "" && strings.HasPrefix(payload, "invite_") {
return bot.handleInviteLink(c, strings.TrimPrefix(payload, "invite_"))
}
welcomeTxt := "🚀 <b>RMSer — ваш умный ассистент для iiko</b>\n\n" +
"Больше не нужно вводить накладные вручную. Просто сфотографируйте чек, а я сделаю всё остальное.\n\n" +
"<b>Почему это удобно:</b>\n" +
"🧠 <b>Самообучение:</b> Сопоставьте товар один раз, и в следующий раз я узнаю его сам.\n" +
"⚙️ <b>Гибкая настройка:</b> Укажите склад по умолчанию и ограничьте область поиска товаров только нужными категориями.\n" +
"👥 <b>Работа в команде:</b> Приглашайте сотрудников, распределяйте роли и управляйте доступом прямо в Mini App.\n\n" +
"🎁 <b>Старт без риска:</b> Дарим 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("✅ Вы подключены к серверу <b>%s</b>.\nВаша роль: <b>%s</b>.\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("🔔 <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)
}
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)
}
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 {
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)
}
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 := "👋 <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 {
userDB, _ := bot.accountRepo.GetUserByTelegramID(c.Sender().ID)
activeServer, _ := bot.accountRepo.GetActiveServer(userDB.ID)
txt := "<b>💰 Баланс и Тарифы</b>\n\n"
if activeServer == nil {
txt += "❌ У вас нет активного сервера. Сначала подключите сервер в меню «Серверы»."
} else {
paidUntil := "не активно"
if activeServer.PaidUntil != nil {
paidUntil = activeServer.PaidUntil.Format("02.01.2006")
}
txt += fmt.Sprintf("🏢 Сервер: <b>%s</b>\n", activeServer.Name)
txt += fmt.Sprintf("📄 Остаток накладных: <b>%d шт.</b>\n", activeServer.Balance)
txt += fmt.Sprintf("📅 Доступен до: <b>%s</b>\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 := "<b>🛒 Выберите тарифный план</b>\n"
if targetURL != "" {
txt += fmt.Sprintf("🎁 Оформление подарка для сервера: <code>%s</code>\n", targetURL)
}
txt += "\n<b>Пакеты (разово):</b>"
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)
// --- INTEGRATION: Billing Callbacks ---
if strings.HasPrefix(data, "bill_") || strings.HasPrefix(data, "buy_id_") || strings.HasPrefix(data, "pay_") {
return bot.handleBillingCallbacks(c, data, userDB)
}
if strings.HasPrefix(data, "set_server_") {
serverIDStr := strings.TrimPrefix(data, "set_server_")
serverIDStr = strings.TrimSpace(serverIDStr)
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx]
}
targetID := parseUUID(serverIDStr)
if err := bot.accountRepo.SetActiveServer(userDB.ID, targetID); err != nil {
logger.Log.Error("Failed to set active server", zap.Error(err))
return c.Respond(&tele.CallbackResponse{Text: "Ошибка: доступ запрещен"})
}
bot.rmsFactory.ClearCacheForUser(userDB.ID)
c.Respond(&tele.CallbackResponse{Text: "✅ Сервер выбран"})
return bot.renderServersMenu(c)
}
if strings.HasPrefix(data, "do_del_server_") {
serverIDStr := strings.TrimPrefix(data, "do_del_server_")
serverIDStr = strings.TrimSpace(serverIDStr)
if idx := strings.Index(serverIDStr, "|"); idx != -1 {
serverIDStr = serverIDStr[:idx]
}
targetID := parseUUID(serverIDStr)
role, err := bot.accountRepo.GetUserRole(userDB.ID, targetID)
if err != nil {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка прав доступа"})
}
if role == account.RoleOwner {
if err := bot.accountRepo.DeleteServer(targetID); err != nil {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка удаления"})
}
bot.rmsFactory.ClearCacheForUser(userDB.ID)
c.Respond(&tele.CallbackResponse{Text: "Сервер полностью удален"})
} else {
if err := bot.accountRepo.RemoveUserFromServer(targetID, userDB.ID); err != nil {
return c.Respond(&tele.CallbackResponse{Text: "Ошибка выхода"})
}
bot.rmsFactory.ClearCacheForUser(userDB.ID)
c.Respond(&tele.CallbackResponse{Text: "Вы покинули сервер"})
}
active, _ := bot.accountRepo.GetActiveServer(userDB.ID)
if active == nil {
all, _ := bot.accountRepo.GetAllAvailableServers(userDB.ID)
if len(all) > 0 {
_ = bot.accountRepo.SetActiveServer(userDB.ID, all[0].ID)
}
}
return bot.renderDeleteServerMenu(c)
}
if strings.HasPrefix(data, "gen_invite_") {
serverIDStr := strings.TrimPrefix(data, "gen_invite_")
link := fmt.Sprintf("https://t.me/%s?start=invite_%s", bot.b.Me.Username, serverIDStr)
c.Respond()
return c.Send(fmt.Sprintf("🔗 <b>Ссылка для приглашения:</b>\n\n<code>%s</code>\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("⚠️ <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)
}
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
}
// Реализация метода интерфейса 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(
"✅ <b>Оплата получена!</b>\n\n"+
"Сумма: <b>%.2f ₽</b>\n"+
"Сервер: <b>%s</b>\n"+
"Текущий баланс: <b>%d накладных</b>\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("⏳ <b>Полная синхронизация</b>\\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("🎁 <b>Режим подарка</b>\n\nВведите URL сервера, который хотите пополнить (например: <code>https://myresto.iiko.it</code>).\n\n<i>Сервер должен быть уже зарегистрирован в нашей системе.</i>", 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("✅ <b>Оплата прошла успешно!</b>\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(
"📦 <b>Заказ №%s</b>\n\nСумма к оплате: <b>%.2f ₽</b>\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("👥 Пользователи сервера <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, 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("🔗 Введите <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)
case StateBillingGiftURL:
if !strings.HasPrefix(text, "http") {
return c.Send("❌ Некорректный URL. Он должен начинаться с http:// или https://")
}
_, err := bot.accountRepo.GetServerByURL(text)
if err != nil {
return c.Send("🔍 Сервер с таким URL не найден в системе. Попросите владельца сначала подключить его к боту.")
}
bot.fsm.UpdateContext(c.Sender().ID, func(ctx *UserContext) {
ctx.BillingTargetURL = text
})
return bot.renderTariffShowcase(c, text)
}
return nil
}
func (bot *Bot) handlePhoto(c tele.Context) error {
userDB, err := bot.accountRepo.GetOrCreateUser(c.Sender().ID, c.Sender().Username, "", "")
if err != nil {
return c.Send("Ошибка базы данных пользователей")
}
_, 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("⏳ <b>ИИ анализирует документ...</b>\nОбрабатываю позиции и ищу совпадения в вашей базе iiko.", tele.ModeHTML)
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>\n\nВсе товары найдены в вашей номенклатуре. Проверьте количество (%d) и цену в приложении перед отправкой.", len(draft.Items))
} else {
msgText = fmt.Sprintf("⚠️ <b>Распознано позиций: %d из %d</b>\n\nОстальные товары я вижу впервые. Воспользуйтесь <b>удобным интерфейсом сопоставления</b> в приложении — я запомню ваш выбор навсегда.", 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>:")
}
// Обновленный метод 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("✅ Сервер <b>%s</b> успешно подключен!\nВаша роль: <b>%s</b>\n\n", server.Name, role)
// Проверяем, новый ли это сервер по балансу и дате создания (упрощенно для уведомления)
if server.Balance == 10 {
successMsg += "🎁 Вам начислен <b>приветственный бонус: 10 накладных</b> на 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("⚙️ <b>Управление серверами</b>\n\nЗдесь вы можете удалить сервер или пригласить сотрудников.", menu, tele.ModeHTML)
}
func parseUUID(s string) uuid.UUID {
id, _ := uuid.Parse(s)
return id
}