Files
tgex-backend/tg_bot/bot/bot.go
Artem Tsyrulnikov e48cd7bccc мини фиксы
2026-02-18 20:21:38 +03:00

428 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"
"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"
)
type exec struct {
handled bool // Событие обработано, дальше не идём
transitioned bool // Был SetState
}
var botUsername string
func SetBotUsername(username string) { botUsername = username }
func BotUsername() string { return botUsername }
type lastRender struct {
textHash string
kbHash string
isMedia bool
}
type Bot struct {
echotron.API
mu sync.Mutex
ChatID int64
exec *exec
CurrentState State
Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками
lastRender lastRender
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")
}
api := echotron.NewAPI(token)
return &Bot{
ChatID: chatID,
API: api,
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 (b *Bot) MarkHandled() {
if b.exec != nil {
b.exec.handled = true
}
}
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.lastRender.isMedia = 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,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
})
if err != nil {
log.Error().Err(err).Msg("SendMessage failed")
return
}
if res.Result == nil {
return
}
b.LastMessageID = res.Result.ID
b.lastRender.isMedia = false
b.lastRender.textHash = hashString(text)
b.lastRender.kbHash = 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.lastRender.textHash
kbChanged := newKBHash != b.lastRender.kbHash
if !textChanged && !kbChanged {
log.Info().Msg("Edit skipped: message not modified")
return
}
var err error
msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID)
if b.lastRender.isMedia {
_, 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,
LinkPreviewOptions: echotron.LinkPreviewOptions{IsDisabled: true},
})
}
if err == nil {
if textChanged {
b.lastRender.textHash = newTextHash
}
if kbChanged {
b.lastRender.kbHash = 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.lastRender.isMedia = 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.Edit(
"❌ Ошибка авторизации",
echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}},
)
b.cleanupCallbackUI(u)
return
}
e := &exec{}
b.exec = e
defer func() { b.exec = nil }()
isStartCommand := u.Message != nil && (u.Message.Text == "/start" ||
u.Message.Text == "/start login" ||
strings.HasPrefix(u.Message.Text, "/start project_"))
if b.CurrentState == nil && !isStartCommand {
b.Session.WasRestarted = true
b.SetState(b.commandRouter("/start"), EditMessage)
return
}
// === Обработка входящего события ===
// 1. Команды (приоритетный маршрут)
if u.Message != nil {
if strings.HasPrefix(u.Message.Text, "/") {
b.SetState(b.commandRouter(u.Message.Text), NewMessage)
return
}
b.CurrentState.HandleMessage(b, u)
if e.handled || e.transitioned {
return
}
}
// 2. Callback запросы
if u.CallbackQuery != nil {
b.cleanupCallbackUI(u)
switch u.CallbackQuery.Data {
case "empty":
return
case "refresh":
if b.CurrentState != nil {
b.CurrentState.Enter(b, EditMessage)
e.handled = true
}
return
}
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) {
var emptyKeyboard = echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
_, 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)
}
}