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 } 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(). 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) forwardSystemEvent(u *echotron.Update) { ctx := context.Background() switch { case u.ChatJoinRequest != nil: req := u.ChatJoinRequest if !isChannelChat(req.Chat.Type) { return } inviteLink := "" if req.InviteLink != nil { inviteLink = req.InviteLink.InviteLink } if inviteLink == "" { log.Debug().Msg("No invite link in chat join request") return } userID := req.From.ID username := stringPtr(req.From.Username) firstName := stringPtr(req.From.FirstName) lastName := stringPtr(req.From.LastName) if userID == 0 && req.UserChatID != 0 { userID = req.UserChatID } if userID == 0 { log.Error().Msg("No user id in chat join request") return } payload := map[string]any{ "type": "subscription", "user_telegram_id": userID, "invite_link": inviteLink, "username": username, "first_name": firstName, "last_name": lastName, } if err := b.Backend.SendEvent(ctx, payload); err != nil { log.Error().Err(err).Msg("Failed to send subscription event") } case u.ChatMember != nil: event := u.ChatMember if !isChannelChat(event.Chat.Type) { return } memberUser := event.NewChatMember.User if memberUser == nil { log.Error().Msg("No user in chat member update") return } oldStatus := event.OldChatMember.Status newStatus := event.NewChatMember.Status wasNotMember := oldStatus == "left" || oldStatus == "kicked" isNowMember := newStatus == "member" || newStatus == "administrator" || newStatus == "creator" userJoined := wasNotMember && isNowMember wasMember := oldStatus == "member" || oldStatus == "administrator" || oldStatus == "creator" || oldStatus == "restricted" isNowNotMember := newStatus == "left" || newStatus == "kicked" userLeft := wasMember && isNowNotMember if userJoined { inviteLink := "" if event.InviteLink != nil { inviteLink = event.InviteLink.InviteLink } if inviteLink == "" { log.Debug().Msg("No invite link in chat member update") return } username := stringPtr(memberUser.Username) firstName := stringPtr(memberUser.FirstName) lastName := stringPtr(memberUser.LastName) payload := map[string]any{ "type": "subscription", "user_telegram_id": memberUser.ID, "invite_link": inviteLink, "username": username, "first_name": firstName, "last_name": lastName, } if err := b.Backend.SendEvent(ctx, payload); err != nil { log.Error().Err(err).Msg("Failed to send subscription event") } return } if userLeft { payload := map[string]any{ "type": "unsubscription", "user_telegram_id": memberUser.ID, "channel_telegram_id": event.Chat.ID, } if err := b.Backend.SendEvent(ctx, payload); err != nil { log.Error().Err(err).Msg("Failed to send unsubscription event") } } case u.MyChatMember != nil: event := u.MyChatMember if !isChannelChat(event.Chat.Type) { return } actorID := event.From.ID if actorID == 0 { log.Error().Msg("No user in my chat member update") return } oldStatus := event.OldChatMember.Status newStatus := event.NewChatMember.Status wasMember := oldStatus == "administrator" || oldStatus == "member" isNowNotMember := newStatus == "left" || newStatus == "kicked" botRemoved := wasMember && isNowNotMember permissionsChanged := oldStatus == "administrator" && newStatus == "administrator" if botRemoved { payload := map[string]any{ "type": "bot_removed", "telegram_id": event.Chat.ID, "user_telegram_id": actorID, } if err := b.Backend.SendEvent(ctx, payload); err != nil { log.Error().Err(err).Msg("Failed to send bot removed event") } return } if permissionsChanged { title := event.Chat.Title if title == "" { title = fmt.Sprintf("Channel %d", event.Chat.ID) } payload := map[string]any{ "type": "bot_permissions", "telegram_id": event.Chat.ID, "chat_title": title, "user_telegram_id": actorID, "permissions": map[string]any{ "is_admin": newStatus == "administrator", "can_invite_users": event.NewChatMember.CanInviteUsers, "can_restrict_members": event.NewChatMember.CanRestrictMembers, }, } if err := b.Backend.SendEvent(ctx, payload); err != nil { log.Error().Err(err).Msg("Failed to send bot permissions event") } return } wasNotMember := oldStatus == "left" || oldStatus == "kicked" isNowMember := newStatus == "administrator" || newStatus == "member" botAdded := wasNotMember && isNowMember if botAdded { title := event.Chat.Title if title == "" { title = fmt.Sprintf("Channel %d", event.Chat.ID) } payload := map[string]any{ "type": "bot_added", "telegram_id": event.Chat.ID, "title": title, "username": stringPtr(event.Chat.Username), "user_telegram_id": actorID, "bot_permissions": map[string]any{ "is_admin": newStatus == "administrator", "can_invite_users": event.NewChatMember.CanInviteUsers, "can_restrict_members": event.NewChatMember.CanRestrictMembers, }, } if err := b.Backend.SendEvent(ctx, payload); err != nil { log.Error().Err(err).Msg("Failed to send bot added event") } } } } 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) { if mode == EditMessage { b.Edit(text, keyboard) } else { b.SendNew(text, keyboard) } } 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.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 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.LastMessageID != 0 { log.Info(). Int("last_msg_id", b.LastMessageID). Int("keyboard_rows", len(keyboard.InlineKeyboard)). Int("text_len", len(text)). Msg("EditMessageText request") textHash := hashString(text) kbHash := keyboardHash(keyboard) if textHash == b.LastMessageTextHash && kbHash == b.LastMessageKBHash { log.Info().Msg("EditMessageText skipped: message is not modified") if b.exec != nil { b.exec.handled = true } return } var err error if b.LastMessageIsMedia { _, err = b.EditMessageCaption( echotron.NewMessageID(b.ChatID, b.LastMessageID), &echotron.MessageCaptionOptions{ Caption: text, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) if err == nil { b.LastMessageTextHash = textHash b.LastMessageKBHash = kbHash } } else { _, err = b.EditMessageText( text, echotron.NewMessageID(b.ChatID, b.LastMessageID), &echotron.MessageTextOptions{ ReplyMarkup: keyboard, ParseMode: echotron.HTML, }, ) if err == nil { b.LastMessageTextHash = textHash b.LastMessageKBHash = kbHash } } if err != nil { if strings.Contains(err.Error(), "message is not modified") { log.Err(err).Msg("EditMessageText ignored: message is not modified") if b.exec != nil { b.exec.handled = true } return } if strings.Contains(err.Error(), "no text in the message to edit") { b.LastMessageIsMedia = true log.Err(err).Msg("EditMessageText ignored: message has no text") 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 b.LastMessageIsMedia = false b.LastMessageTextHash = hashString(text) b.LastMessageKBHash = keyboardHash(keyboard) } } 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). 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. Системные события -> отправляем доменные события в 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 } 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 - ререндер текущего экрана, чтобы не терять прогресс if b.CurrentState != nil { mode := NewMessage if u.CallbackQuery != nil { mode = EditMessage } b.CurrentState.Enter(b, mode) if exec.handled || exec.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) } }