diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index c3d1754..856cca6 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -18,16 +18,16 @@ var emptyKeyboard = echotron.InlineKeyboardMarkup{ InlineKeyboard: [][]echotron.InlineKeyboardButton{}, } -type Exec struct { - handled bool - transitioned bool +type exec struct { + handled bool // Событие обработано, дальше не идём + transitioned bool // Был SetState } type Bot struct { echotron.API mu sync.Mutex ChatID int64 - exec *Exec + exec *exec CurrentState State Session *Session LastMessageID int // ID последнего сообщения с inline кнопками @@ -101,14 +101,8 @@ func (b *Bot) GetOrCreateJWT(u *echotron.Update) error { } 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") + log.Info().Msg(fmt.Sprintf("State transition: %T -> %T", b.CurrentState, s)) - // Вызываем Exit() у старого состояния для cleanup (например, отмены горутин) if b.CurrentState != nil { b.CurrentState.Exit() } @@ -123,193 +117,6 @@ func (b *Bot) SetState(s State, mode RenderMode) { 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" } @@ -322,10 +129,13 @@ func stringPtr(value string) *string { } func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode RenderMode) { - if mode == EditMessage { + switch mode { + case EditMessage: b.Edit(text, keyboard) - } else { + case NewMessage: b.SendNew(text, keyboard) + default: + panic("unknown RenderMode") } } @@ -345,131 +155,82 @@ func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) { } 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 b.exec != nil { - b.exec.handled = true - } - + 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 { - 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") + 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.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 { + if b.exec != nil { + defer func() { b.exec.handled = true }() + } + + if b.LastMessageID == 0 { b.SendNew(text, keyboard) + return } - if b.exec != nil { - b.exec.handled = true - } -} + newTextHash := hashString(text) + newKBHash := keyboardHash(keyboard) -func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { - keyboard := echotron.InlineKeyboardMarkup{ - InlineKeyboard: [][]echotron.InlineKeyboardButton{ - { - { - Text: buttonText, - URL: url, - }, - }, - }, + textChanged := newTextHash != b.LastMessageTextHash + kbChanged := newKBHash != b.LastMessageKBHash + + if !textChanged && !kbChanged { + log.Info().Msg("Edit skipped: message not modified") + return } - res, err := b.SendMessage(text, b.ChatID, &echotron.MessageOptions{ - ReplyMarkup: keyboard, - }) - if err != nil { - log.Error().Err(err).Msg("SendMessageWithURLButton") + 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 b.exec != nil { - b.exec.handled = true + if err == nil { + if textChanged { + b.LastMessageTextHash = newTextHash + } + if kbChanged { + b.LastMessageKBHash = newKBHash + } + return } - if err == nil && res.Result != nil { - b.LastMessageID = res.Result.ID - b.LastMessageIsMedia = false - b.LastMessageTextHash = hashString(text) - b.LastMessageKBHash = keyboardHash(keyboard) + 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 { @@ -490,13 +251,7 @@ 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") + log.Info().Int64("chat_id", b.ChatID).Msg("Update received") // 1. Системные события -> отправляем доменные события в backend if u.ChatJoinRequest != nil || u.ChatMember != nil || u.MyChatMember != nil { @@ -533,8 +288,8 @@ func (b *Bot) Update(u *echotron.Update) { return } - exec := &Exec{} - b.exec = exec + e := &exec{} + b.exec = e defer func() { b.exec = nil }() if b.CurrentState == nil { @@ -554,7 +309,7 @@ func (b *Bot) Update(u *echotron.Update) { return default: b.CurrentState.HandleMessage(b, u) - if exec.handled || exec.transitioned { + if e.handled || e.transitioned { return } } @@ -575,14 +330,14 @@ func (b *Bot) Update(u *echotron.Update) { } b.CurrentState.HandleCallback(b, u) - if exec.handled || exec.transitioned { + if e.handled || e.transitioned { return } } // 3. Универсальный обработчик состояния (если ничего не подошло) b.CurrentState.Handle(b, u) - if exec.handled || exec.transitioned { + if e.handled || e.transitioned { return } @@ -593,7 +348,7 @@ func (b *Bot) Update(u *echotron.Update) { mode = EditMessage } b.CurrentState.Enter(b, mode) - if exec.handled || exec.transitioned { + if e.handled || e.transitioned { return } } diff --git a/tg_bot/bot/forward_system_event.go b/tg_bot/bot/forward_system_event.go new file mode 100644 index 0000000..a3808d6 --- /dev/null +++ b/tg_bot/bot/forward_system_event.go @@ -0,0 +1,196 @@ +package bot + +import ( + "context" + "fmt" + + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +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") + } + } + } +}