This commit is contained in:
Artem Tsyrulnikov
2026-01-18 13:59:47 +03:00
parent d8202e1141
commit 62e8f98429
62 changed files with 156 additions and 190 deletions

371
tg_bot/bot/bot.go Normal file
View File

@@ -0,0 +1,371 @@
package bot
import (
"context"
"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
}
type Bot struct {
echotron.API
mu sync.Mutex
ChatID int64
exec *Exec
CurrentState State
Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками
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().
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")
// Вызываем Exit() у старого состояния для cleanup (например, отмены горутин)
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) 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")
// 1. Системные события (ChatJoinRequest, ChatMember, etc.) -> форвардим асинхронно
if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil {
go func() {
ctx := context.Background()
raw, err := json.Marshal(u)
if err != nil {
log.Error().RawJSON("update", raw).Msg("Failed to marshal update")
}
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
}
// 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
}
exec := &Exec{}
b.exec = exec
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 exec.handled || exec.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 exec.handled || exec.transitioned {
return
}
}
// 3. Универсальный обработчик состояния (если ничего не подошло)
b.CurrentState.Handle(b, u)
if exec.handled || exec.transitioned {
return
}
// 4. 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)
}
}

23
tg_bot/bot/state.go Normal file
View File

@@ -0,0 +1,23 @@
package bot
import "github.com/NicoNex/echotron/v3"
type RenderMode int
const (
NewMessage RenderMode = iota
EditMessage
)
type State interface {
Enter(*Bot, RenderMode)
HandleCallback(*Bot, *echotron.Update)
HandleMessage(*Bot, *echotron.Update)
Handle(*Bot, *echotron.Update)
Exit() // Вызывается при выходе из состояния для cleanup (например, отмены горутин)
}
type Session struct {
FirstName string
JWT string
}