Files
tgex-backend/telegram_bot/bot/bot.go
2026-01-07 12:23:02 +03:00

415 lines
11 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"
"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)
}
}