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