Files
tgex-backend/telegram_bot/bot/bot.go
Artem Tsyrulnikov 922067d194 bot changes
2026-01-03 11:24:24 +03:00

296 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
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
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 {
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 {
_, 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)
}
}