Files
tgex-backend/tg_bot/bot/bot.go
Artem Tsyrulnikov 7f6527652b refactor
2026-01-20 14:05:38 +03:00

387 lines
10 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"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/rs/zerolog/log"
)
var emptyKeyboard = echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
type exec struct {
handled bool // Событие обработано, дальше не идём
transitioned bool // Был SetState
}
type Bot struct {
echotron.API
mu sync.Mutex
ChatID int64
exec *exec
CurrentState State
Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками
LastMessageIsMedia bool // True if LastMessageID points to media message
LastMessageTextHash string // Hash for last text/caption
LastMessageKBHash string // Hash for last inline keyboard
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().Msg(fmt.Sprintf("State transition: %T -> %T", b.CurrentState, s))
if b.CurrentState != nil {
b.CurrentState.Exit()
}
b.CurrentState = s
if b.exec != nil {
b.exec.transitioned = true
b.exec.handled = true
}
s.Enter(b, mode)
}
func isChannelChat(chatType string) bool {
return chatType == "channel" || chatType == "supergroup"
}
func stringPtr(value string) *string {
if value == "" {
return nil
}
return &value
}
func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) {
switch mode {
case EditMessage:
b.Edit(text, keyboard)
case NewMessage:
b.SendNew(text, keyboard)
default:
panic("unknown RenderMode")
}
}
func (b *Bot) SetLastMessageIsMedia(isMedia bool) {
b.LastMessageIsMedia = isMedia
}
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.exec != nil {
defer func() { b.exec.handled = true }()
}
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 err != nil {
log.Error().Err(err).Msg("SendMessage failed")
return
}
if res.Result == nil {
return
}
b.LastMessageID = res.Result.ID
b.LastMessageIsMedia = false
b.LastMessageTextHash = hashString(text)
b.LastMessageKBHash = keyboardHash(keyboard)
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.exec != nil {
defer func() { b.exec.handled = true }()
}
if b.LastMessageID == 0 {
b.SendNew(text, keyboard)
return
}
newTextHash := hashString(text)
newKBHash := keyboardHash(keyboard)
textChanged := newTextHash != b.LastMessageTextHash
kbChanged := newKBHash != b.LastMessageKBHash
if !textChanged && !kbChanged {
log.Info().Msg("Edit skipped: message not modified")
return
}
var err error
msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID)
if b.LastMessageIsMedia {
_, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{Caption: text, ParseMode: echotron.HTML, ReplyMarkup: keyboard})
} else {
_, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML})
}
if err == nil {
if textChanged {
b.LastMessageTextHash = newTextHash
}
if kbChanged {
b.LastMessageKBHash = newKBHash
}
return
}
switch {
case strings.Contains(err.Error(), "message is not modified"):
log.Err(err).Msg("EditMessageText ignored: message is not modified")
case strings.Contains(err.Error(), "no text in the message to edit"):
b.LastMessageIsMedia = true
log.Err(err).Msg("EditMessageText ignored: message has no text")
default:
log.Error().Err(err).Msg("EditMessageText")
}
}
func keyboardHash(kb echotron.InlineKeyboardMarkup) string {
b, err := json.Marshal(kb)
if err != nil {
return ""
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func hashString(v string) string {
sum := sha256.Sum256([]byte(v))
return hex.EncodeToString(sum[:])
}
func (b *Bot) Update(u *echotron.Update) {
b.mu.Lock()
defer b.mu.Unlock()
log.Info().Int64("chat_id", b.ChatID).Msg("Update received")
// 1. Системные события -> отправляем доменные события в backend
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
go b.forwardSystemEvent(u)
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
}
e := &exec{}
b.exec = e
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 e.handled || e.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 e.handled || e.transitioned {
return
}
}
// 3. Универсальный обработчик состояния (если ничего не подошло)
b.CurrentState.Handle(b, u)
if e.handled || e.transitioned {
return
}
// 4. Fallback - ререндер текущего экрана, чтобы не терять прогресс
if b.CurrentState != nil {
mode := NewMessage
if u.CallbackQuery != nil {
mode = EditMessage
}
b.CurrentState.Enter(b, mode)
if e.handled || e.transitioned {
return
}
}
// 5. Крайний 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)
}
}