package bot import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "strings" "sync" "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/backend" "github.com/rs/zerolog/log" ) type exec struct { handled bool // Событие обработано, дальше не идём transitioned bool // Был SetState } var botUsername string var botUsernameOnce sync.Once func SetBotUsername(username string) { botUsername = username } func BotUsername() string { return botUsername } func ensureBotUsername(api echotron.API) { if botUsername != "" { return } botUsernameOnce.Do(func() { me, err := api.GetMe() if err != nil { log.Error().Err(err).Msg("GetMe failed, bot username will be empty") return } if me.Result != nil { botUsername = me.Result.Username } }) } type lastRender struct { textHash string kbHash string isMedia bool } type Bot struct { echotron.API mu sync.Mutex ChatID int64 exec *exec CurrentState State Session *Session LastMessageID int // ID последнего сообщения с inline кнопками lastRender lastRender commandRouter func(cmd string) State // Роутер для команд (например, /start, /help) globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений Backend *backend.Client } func NewBot(chatID int64, token string, commandRouter func(string) State, backendClient *backend.Client, globalCallbackRouter func(string) State) *Bot { if commandRouter == nil { panic("bot: commandRouter cannot be nil") } if globalCallbackRouter == nil { panic("bot: globalCallbackRouter cannot be nil") } if commandRouter("/start") == nil { panic("bot: commandRouter requires /start state answer") } if backendClient == nil { panic("bot: backendClient cannot be nil") } api := echotron.NewAPI(token) ensureBotUsername(api) return &Bot{ ChatID: chatID, API: api, Session: &Session{}, commandRouter: commandRouter, Backend: backendClient, globalCallbackRouter: globalCallbackRouter, } } // GetOrCreateJWT получает JWT токен, передавая актуальные данные пользователя из update func (b *Bot) GetOrCreateJWT(u *echotron.Update) error { // Извлекаем данные пользователя из update var user *echotron.User if u.Message != nil && u.Message.From != nil { user = u.Message.From } else if u.CallbackQuery != nil && u.CallbackQuery.From != nil { user = u.CallbackQuery.From } var username, firstName, lastName *string if user != nil { if user.Username != "" { username = &user.Username } if user.FirstName != "" { firstName = &user.FirstName } if user.LastName != "" { lastName = &user.LastName } } jwt, err := b.Backend.GetJWTByTelegramUser(context.Background(), b.ChatID, username, firstName, lastName) if err != nil { return err } b.Session.JWT = jwt if firstName != nil { b.Session.FirstName = *firstName } return nil } func (b *Bot) SetState(s State, mode RenderMode) { log.Info().Msg(fmt.Sprintf("State transition: %T -> %T", b.CurrentState, s)) if b.CurrentState != nil { b.CurrentState.Exit() } b.CurrentState = s if b.exec != nil { b.exec.transitioned = true b.exec.handled = true } s.Enter(b, mode) } func isChannelChat(chatType string) bool { return chatType == "channel" || chatType == "supergroup" } func stringPtr(value string) *string { if value == "" { return nil } return &value } func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) { switch mode { case EditMessage: b.Edit(text, keyboard) case NewMessage: b.SendNew(text, keyboard) default: panic("unknown RenderMode") } } func (b *Bot) SetLastMessageIsMedia(isMedia bool) { b.lastRender.isMedia = isMedia } func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) { res, err := b.GetFile(fileID) if err != nil { return nil, err } if res.Result == nil || res.Result.FilePath == "" { return nil, fmt.Errorf("telegram file not available") } return b.DownloadFile(res.Result.FilePath) } func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { if b.exec != nil { defer func() { b.exec.handled = true }() } if b.LastMessageID != 0 { log.Info().Int("cleanup_msg_id", b.LastMessageID).Msg("Cleaning up keyboard before SendNew") b.cleanupKeyboard(b.LastMessageID) } res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML}) if err != nil { log.Error().Err(err).Msg("SendMessage failed") return } if res.Result == nil { return } b.LastMessageID = res.Result.ID b.lastRender.isMedia = false b.lastRender.textHash = hashString(text) b.lastRender.kbHash = keyboardHash(keyboard) log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew") } func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { if b.exec != nil { defer func() { b.exec.handled = true }() } if b.LastMessageID == 0 { b.SendNew(text, keyboard) return } newTextHash := hashString(text) newKBHash := keyboardHash(keyboard) textChanged := newTextHash != b.lastRender.textHash kbChanged := newKBHash != b.lastRender.kbHash if !textChanged && !kbChanged { log.Info().Msg("Edit skipped: message not modified") return } var err error msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID) if b.lastRender.isMedia { _, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{Caption: text, ParseMode: echotron.HTML, ReplyMarkup: keyboard}) } else { _, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML}) } if err == nil { if textChanged { b.lastRender.textHash = newTextHash } if kbChanged { b.lastRender.kbHash = newKBHash } return } switch { case strings.Contains(err.Error(), "message is not modified"): log.Err(err).Msg("EditMessageText ignored: message is not modified") case strings.Contains(err.Error(), "no text in the message to edit"): b.lastRender.isMedia = true log.Err(err).Msg("EditMessageText ignored: message has no text") default: log.Error().Err(err).Msg("EditMessageText") } } func keyboardHash(kb echotron.InlineKeyboardMarkup) string { b, err := json.Marshal(kb) if err != nil { return "" } sum := sha256.Sum256(b) return hex.EncodeToString(sum[:]) } func hashString(v string) string { sum := sha256.Sum256([]byte(v)) return hex.EncodeToString(sum[:]) } func (b *Bot) Update(u *echotron.Update) { b.mu.Lock() defer b.mu.Unlock() log.Info().Int64("chat_id", b.ChatID).Msg("Update received") // 1. Системные события -> отправляем доменные события в backend if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { go b.forwardSystemEvent(u) return } // 2. Игнорируем события не из private чатов chatType := "" switch { case u.Message != nil: chatType = u.Message.Chat.Type case u.CallbackQuery != nil && u.CallbackQuery.Message != nil: chatType = u.CallbackQuery.Message.Chat.Type case u.EditedMessage != nil: chatType = u.EditedMessage.Chat.Type case u.ChannelPost != nil || u.EditedChannelPost != nil: return // Каналы игнорируем сразу } if chatType != "" && chatType != "private" { log.Debug().Str("chat_type", chatType).Msg("Ignoring non-private chat") return } // 3. Игнорируем edited messages if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil { log.Debug().Msg("Ignoring edited message") return } err := b.GetOrCreateJWT(u) if err != nil || b.Session.JWT == "" { b.Edit( "❌ Ошибка авторизации", echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}}, ) return } e := &exec{} b.exec = e defer func() { b.exec = nil }() isStartCommand := u.Message != nil && (u.Message.Text == "/start" || u.Message.Text == "/start login" || strings.HasPrefix(u.Message.Text, "/start project_")) if b.CurrentState == nil && !isStartCommand { _, err := b.SendMessage( "⚠️ Бот был перезапущен. Извиняемся за неудобства :(\n\nВас отправили в главное меню", b.ChatID, &echotron.MessageOptions{ParseMode: echotron.HTML}, ) if err != nil { log.Error().Err(err).Msg("SendMessage failed while sending restart notice") } b.SetState(b.commandRouter("/start"), EditMessage) } // === Обработка входящего события === // 1. Команды (приоритетный маршрут) if u.Message != nil { switch { case u.Message.Text == "/start": b.SetState(b.commandRouter("/start"), NewMessage) return case u.Message.Text == "/start login": b.SetState(b.commandRouter("/start login"), NewMessage) return case strings.HasPrefix(u.Message.Text, "/start project_"): b.SetState(b.commandRouter(u.Message.Text), NewMessage) return } b.CurrentState.HandleMessage(b, u) if e.handled || e.transitioned { return } } // 2. Callback запросы if u.CallbackQuery != nil { b.cleanupCallbackUI(u) switch u.CallbackQuery.Data { case "empty": return case "refresh": if b.CurrentState != nil { b.CurrentState.Enter(b, EditMessage) e.handled = true } return } if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil { b.SetState(newState, NewMessage) return } b.CurrentState.HandleCallback(b, u) if e.handled || e.transitioned { return } } // 3. Универсальный обработчик состояния (если ничего не подошло) b.CurrentState.Handle(b, u) if e.handled || e.transitioned { return } // 4. Fallback - ререндер текущего экрана, чтобы не терять прогресс if b.CurrentState != nil { mode := NewMessage if u.CallbackQuery != nil { mode = EditMessage } b.CurrentState.Enter(b, mode) if e.handled || e.transitioned { return } } // 5. Крайний fallback - сброс в начальное состояние b.SetState(b.commandRouter("/start"), NewMessage) } func (b *Bot) cleanupKeyboard(messageID int) { var emptyKeyboard = echotron.InlineKeyboardMarkup{ InlineKeyboard: [][]echotron.InlineKeyboardButton{}, } _, err := b.EditMessageReplyMarkup( echotron.NewMessageID(b.ChatID, messageID), &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, ) if err != nil { log.Error().Err(err).Int("message_id", messageID).Msg("Failed to remove keyboard") } } func (b *Bot) cleanupCallbackUI(u *echotron.Update) { _, err := b.AnswerCallbackQuery(u.CallbackQuery.ID, nil) if err != nil { log.Error().Err(err).Msg("b.AnswerCallbackQuery error") } if u.CallbackQuery.Message != nil { callbackMessageID := u.CallbackQuery.Message.ID // Если это сообщение последнее, не удаляем клавиатуру if b.LastMessageID == callbackMessageID { return } b.cleanupKeyboard(callbackMessageID) } }