package bot import ( "context" "encoding/json" "fmt" "strings" "sync" "example.com/m/adapter/backend" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) func Button(text, data string) echotron.InlineKeyboardButton { return echotron.InlineKeyboardButton{ Text: text, CallbackData: data, } } func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons } func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup { if len(rows) == 0 { return echotron.InlineKeyboardMarkup{ InlineKeyboard: [][]echotron.InlineKeyboardButton{}, } } return echotron.InlineKeyboardMarkup{ InlineKeyboard: rows, } } 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 кнопками DefaultStateFactory func() State Backend *backend.Client GlobalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений } func NewBot( chatID int64, token string, defaultStateFactory func() State, backendClient *backend.Client, globalCallbackRouter func(string) State, ) *Bot { bot := &Bot{ ChatID: chatID, API: echotron.NewAPI(token), Session: &Session{}, DefaultStateFactory: defaultStateFactory, Backend: backendClient, GlobalCallbackRouter: globalCallbackRouter, } return bot } // 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 с актуальными данными пользователя (или создаем нового если не существует) jwt, err := b.Backend.GetJWTByTelegramUser( context.Background(), b.ChatID, username, firstName, lastName, ) if err != nil { return err } // Сохраняем в сессию b.Session.JWT = jwt if username != nil { b.Session.UserName = *username } 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) RemoveLastKeyboard() { // if b.LastMessageID != 0 { // emptyKeyboard := Keyboard() // Пустая клавиатура // _, err := b.EditMessageReplyMarkup( // echotron.NewMessageID(b.ChatID, b.LastMessageID), // &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, // ) // if err != nil { // log.Error().Err(err).Msg("RemoveLastKeyboard: b.EditMessageReplyMarkup") // } // } //} 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") // Обрабатываем системные события (добавление в канал, подписки и т.д.) if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { go func() { ctx := context.Background() if raw, err := json.Marshal(u); err == nil { log.Info().RawJSON("update", raw).Msg("System event payload") } else { log.Error().Err(err).Msg("Failed to marshal system event payload") } 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 } // Игнорируем все события не из личных чатов (кроме уже обработанных системных) var chatType string switch { case u.Message != nil: chatType = u.Message.Chat.Type case u.EditedMessage != nil: chatType = u.EditedMessage.Chat.Type case u.ChannelPost != nil: chatType = u.ChannelPost.Chat.Type case u.EditedChannelPost != nil: chatType = u.EditedChannelPost.Chat.Type case u.CallbackQuery != nil && u.CallbackQuery.Message != nil: chatType = u.CallbackQuery.Message.Chat.Type } if chatType != "" && chatType != "private" { log.Debug().Str("chat_type", chatType).Msg("Ignoring event from non-private chat") return } // Игнорируем edited_message обновления, чтобы не сбрасывать состояние после наших Edit операций. if u.EditedMessage != nil || u.EditedChannelPost != nil || u.EditedBusinessMessage != nil { log.Debug().Msg("Ignoring edited message update") return } // Получаем или создаем JWT, обновляя данные пользователя (username, first_name, last_name) if err := b.GetOrCreateJWT(u); err != nil { log.Error().Err(err).Msg("Failed to get or create JWT") // Не блокируем работу бота если JWT не удалось получить - это не критично для всех действий } exec := &Exec{} b.exec = exec defer func() { b.exec = nil }() if b.CurrentState == nil && b.DefaultStateFactory != nil { b.CurrentState = b.DefaultStateFactory() } if u.Message != nil && u.Message.Text == "/start" { if b.DefaultStateFactory != nil { b.SetState(b.DefaultStateFactory(), NewMessage) } return } if u.CallbackQuery != nil { log.Info(). Str("callback_data", u.CallbackQuery.Data). Int("callback_msg_id", func() int { if u.CallbackQuery.Message != nil { return u.CallbackQuery.Message.ID } return 0 }()). Int("last_msg_id", b.LastMessageID). Msg("Callback received") b.cleanupCallbackUI(u) if u.CallbackQuery.Data == "empty" || u.CallbackQuery.Data == "page_info" || u.CallbackQuery.Data == "noop" { exec.handled = true return } if b.GlobalCallbackRouter != nil && u.CallbackQuery.Data != "" { if newState := b.GlobalCallbackRouter(u.CallbackQuery.Data); newState != nil { b.SetState(newState, NewMessage) return } } log.Info().Msg("Handling callback") b.CurrentState.HandleCallback(b, u) } else if u.Message != nil { log.Info().Msg("Handling message") b.CurrentState.HandleMessage(b, u) } if !exec.handled && !exec.transitioned { log.Info(). Bool("has_callback", u.CallbackQuery != nil). Bool("has_message", u.Message != nil). Bool("handled", exec.handled). Bool("transitioned", exec.transitioned). Msg("No handler handled update, calling common handler") log.Info().Msg("Common handler") b.CurrentState.Handle(b, u) } if !exec.handled && !exec.transitioned { log.Info(). Bool("has_callback", u.CallbackQuery != nil). Bool("has_message", u.Message != nil). Bool("handled", exec.handled). Bool("transitioned", exec.transitioned). Msg("Default feedback triggered") log.Info().Msg("Default feedback") b.SetState(b.DefaultStateFactory(), NewMessage) } } func (b *Bot) cleanupKeyboard(messageID int) { emptyKeyboard := Keyboard() _, 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) } }