package bot import ( "context" "encoding/json" "fmt" "strings" "sync" "example.com/m/adapter/backend" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) var emptyKeyboard = echotron.InlineKeyboardMarkup{ InlineKeyboard: [][]echotron.InlineKeyboardButton{}, } type Exec struct { handled bool transitioned bool } type Bot struct { echotron.API mu sync.Mutex ChatID int64 exec *Exec CurrentState State Session *Session LastMessageID int // ID последнего сообщения с inline кнопками 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") } return &Bot{ ChatID: chatID, API: echotron.NewAPI(token), 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(). Int64("chat_id", b.ChatID). Str("from_state", fmt.Sprintf("%T", b.CurrentState)). Str("to_state", fmt.Sprintf("%T", s)). Str("mode", fmt.Sprintf("%v", mode)). Msg("State transition") b.CurrentState = s if b.exec != nil { b.exec.transitioned = true b.exec.handled = true } s.Enter(b, mode) } func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) { if mode == EditMessage { b.Edit(text, keyboard) } else { b.SendNew(text, keyboard) } } 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.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 b.exec != nil { b.exec.handled = true } if err != nil { log.Error().Err(err).Msg("SendMessage failed") return } if res.Result != nil { b.LastMessageID = res.Result.ID 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.LastMessageID != 0 { log.Info(). Int("last_msg_id", b.LastMessageID). Int("keyboard_rows", len(keyboard.InlineKeyboard)). Int("text_len", len(text)). Msg("EditMessageText request") _, err := b.EditMessageText( text, echotron.NewMessageID(b.ChatID, b.LastMessageID), &echotron.MessageTextOptions{ ReplyMarkup: keyboard, ParseMode: echotron.HTML, }, ) if err != nil { if strings.Contains(err.Error(), "message is not modified") { log.Info().Msg("EditMessageText ignored: message is not modified") if b.exec != nil { b.exec.handled = true } return } log.Error().Err(err).Msg("EditMessageText") } } else { b.SendNew(text, keyboard) } if b.exec != nil { b.exec.handled = true } } func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { keyboard := echotron.InlineKeyboardMarkup{ InlineKeyboard: [][]echotron.InlineKeyboardButton{ { { Text: buttonText, URL: url, }, }, }, } res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ ReplyMarkup: keyboard, }) if err != nil { log.Error().Err(err).Msg("SendMessageWithURLButton") } if b.exec != nil { b.exec.handled = true } if err == nil && res.Result != nil { b.LastMessageID = res.Result.ID } } func (b *Bot) Update(u *echotron.Update) { b.mu.Lock() defer b.mu.Unlock() log.Info(). Int64("chat_id", b.ChatID). Str("current_state", fmt.Sprintf("%T", b.CurrentState)). Bool("has_callback", u.CallbackQuery != nil). Bool("has_message", u.Message != nil). Bool("has_edited_message", u.EditedMessage != nil). Msg("Update received") // 1. Системные события (ChatJoinRequest, ChatMember, etc.) -> форвардим асинхронно if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { go func() { ctx := context.Background() raw, err := json.Marshal(u) if err != nil { log.Error().RawJSON("update", raw).Msg("Failed to marshal update") } err = b.Backend.ForwardTelegramUpdate(ctx, u) if err != nil { log.Error().Err(err).Msg("Failed to forward system event to backend") return } log.Info().Msg("System event forwarded to backend") }() 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.SendNew("❌ Ошибка авторизации. Попробуйте /start", emptyKeyboard) return } exec := &Exec{} b.exec = exec defer func() { b.exec = nil }() if b.CurrentState == nil { b.CurrentState = b.commandRouter("/start") } // === Обработка входящего события === // 1. Команды (приоритетный маршрут) if u.Message != nil { switch u.Message.Text { case "/start": b.SetState(b.commandRouter("/start"), NewMessage) return case "/start login": b.SetState(b.commandRouter("/start login"), NewMessage) return default: b.CurrentState.HandleMessage(b, u) if exec.handled || exec.transitioned { return } } } // 2. Callback запросы if u.CallbackQuery != nil { b.cleanupCallbackUI(u) if u.CallbackQuery.Data == "empty" { return } // Глобальные callbacks (из уведомлений) if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil { b.SetState(newState, NewMessage) return } b.CurrentState.HandleCallback(b, u) if exec.handled || exec.transitioned { return } } // 3. Универсальный обработчик состояния (если ничего не подошло) b.CurrentState.Handle(b, u) if exec.handled || exec.transitioned { return } // 4. Fallback - сброс в начальное состояние b.SetState(b.commandRouter("/start"), NewMessage) } func (b *Bot) cleanupKeyboard(messageID int) { _, 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) } }