mirror of
https://github.com/serty2005/rmser.git
synced 2026-02-04 19:02:33 -06:00
912 lines
36 KiB
Go
912 lines
36 KiB
Go
// 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")
|
||
btnBack := bot.menuDicts.Data("🔙 Назад", "nav_main")
|
||
bot.menuDicts.Inline(
|
||
bot.menuDicts.Row(btnSync),
|
||
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_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) 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); 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)
|
||
|
||
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
|
||
}
|