package bot import ( "context" "fmt" "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 { return echotron.InlineKeyboardMarkup{ InlineKeyboard: rows, } } type Exec struct { handled bool transitioned bool } type Bot struct { echotron.API 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 } 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) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { // Удаляем кнопки у предыдущего сообщения перед отправкой нового if b.LastMessageID != 0 { 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 } } func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { if b.LastMessageID != 0 { _, err := b.EditMessageText( text, echotron.NewMessageID(b.ChatID, b.LastMessageID), &echotron.MessageTextOptions{ ReplyMarkup: keyboard, ParseMode: echotron.HTML, }, ) if err != nil { 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) { log.Info(). Int64("chat_id", b.ChatID). Str("current_state", fmt.Sprintf("%T", b.CurrentState)). Msg("Update received") // Обрабатываем системные события (добавление в канал, подписки и т.д.) if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { go func() { ctx := context.Background() 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 } 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 { b.cleanupCallbackUI(u) 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().Msg("Common handler") b.CurrentState.Handle(b, u) } if !exec.handled && !exec.transitioned { 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) } }