diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index fba8ab9..e74bed0 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -341,6 +341,7 @@ func (b *Bot) Update(u *echotron.Update) { log.Error().Err(err).Msg("SendMessage failed while sending restart notice") } b.SetState(b.commandRouter("/start"), EditMessage) + return } // === Обработка входящего события === diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go index 7443666..2ee18cf 100644 --- a/tg_bot/screens/add_creative.go +++ b/tg_bot/screens/add_creative.go @@ -17,8 +17,9 @@ import ( type AddCreativeCtx struct { CreativeEditorFields - ProjectID string - BackState bot.State + ProjectID string + BackState bot.State + WaitMessageSent bool } type AddCreativeStart struct{ Ctx *AddCreativeCtx } @@ -40,9 +41,9 @@ const msgWaitCreative = ` const msgConfirmCreativeFormat = ` ➕ Добавляем этот креатив? -Название креатива: %s +Название: %s -Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов. +Чтобы изменить название, отправь мне сообщение 👇: ` const msgDownloadMediaError = ` @@ -93,9 +94,31 @@ const msgCreativeCreatedButtons = ` ` +func filterCreateButtons(rows [][]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton { + filtered := make([][]echotron.InlineKeyboardButton, 0, len(rows)) + for _, row := range rows { + skip := false + for _, btn := range row { + switch btn.CallbackData { + case "edit_text", "add_media", "delete_media": + skip = true + } + } + if !skip { + filtered = append(filtered, row) + } + } + return filtered +} + func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) { + if s.Ctx.WaitMessageSent { + return + } + kb := Keyboard(Row(Button("« Отмена", "cancel"))) b.Render(msgWaitCreative, kb, mode) + s.Ctx.WaitMessageSent = true } func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) { @@ -118,7 +141,15 @@ func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) { s.Ctx.Name = &name } - s.Ctx.DeleteUserMessage(b, u.Message.ID) + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.Ctx.scheduleMediaGroupAction(groupID, func() { + b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage) + }) + b.MarkHandled() + return + } + b.SetState(&AddCreativeEdit{ctx: s.Ctx}, bot.NewMessage) } @@ -153,8 +184,7 @@ func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) { s.ctx.InputMode = inputModeMedia b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) case data == "delete_media": - s.ctx.MediaType = "" - s.ctx.MediaFileID = "" + s.ctx.MediaItems = nil s.ctx.MediaChanged = true s.ctx.UpdateCreativePreview(b) s.Enter(b, bot.EditMessage) @@ -175,7 +205,24 @@ func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) { } func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) { - if u.Message == nil || u.Message.Text == "" { + if u.Message == nil { + return + } + + if u.Message.Text == "" { + if s.ctx.SetMediaFromMessage(u.Message) { + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.ctx.scheduleMediaGroupAction(groupID, func() { + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) + }) + b.MarkHandled() + return + } + s.ctx.UpdateCreativePreview(b) + s.Enter(b, bot.EditMessage) + } return } @@ -284,10 +331,19 @@ func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) { if !s.ctx.SetMediaFromMessage(u.Message) { return } - s.ctx.DeleteUserMessage(b, u.Message.ID) + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.ctx.scheduleMediaGroupAction(groupID, func() { + s.ctx.UpdateCreativePreview(b) + s.ctx.ShowMediaPanel(b) + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) + }) + b.MarkHandled() + return + } s.ctx.UpdateCreativePreview(b) - s.ctx.ClearInputMode() - b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) + s.ctx.ShowMediaPanel(b) + b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) } } @@ -345,18 +401,18 @@ func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) { escapedName := ui.EscapeHTML(*s.Name) confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName) - buttons := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel") + buttons := filterCreateButtons(s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel")) s.ShowControlPanel(b, confirmText, buttons) } func (s *AddCreativeCtx) createCreative(b *bot.Bot) { var err error - var mediaData []byte - if s.MediaFileID != "" { - mediaData, err = b.DownloadFileBytes(s.MediaFileID) + var mediaItems []backend.CreativeMediaInput + if len(s.MediaItems) > 0 { + mediaItems, err = s.BuildMediaInputs(b) if err != nil { log.Error().Err(err).Msg("Failed to download creative media") - buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel") + buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")) s.ShowControlPanel(b, msgDownloadMediaError, buttons) return } @@ -372,14 +428,16 @@ func (s *AddCreativeCtx) createCreative(b *bot.Bot) { log.Info().Str("text", *s.Text).Msg("Creative text") - creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{ - Name: *s.Name, - Text: *s.Text, - MediaType: s.MediaType, - MediaFileID: s.MediaFileID, - MediaData: mediaData, - Buttons: buttons, - }) + input := backend.CreateCreativeInput{ + Name: *s.Name, + Text: *s.Text, + Buttons: buttons, + } + if len(mediaItems) > 0 { + input.MediaItems = mediaItems + } + + creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, input) if err != nil { log.Error().Err(err).Msg("Failed to create creative") @@ -395,7 +453,7 @@ func (s *AddCreativeCtx) createCreative(b *bot.Bot) { userMsg = msgCreateError } - buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel") + buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")) s.ShowControlPanel(b, userMsg, buttons) return } diff --git a/tg_bot/screens/creative_details.go b/tg_bot/screens/creative_details.go index 75364ad..d11d2ba 100644 --- a/tg_bot/screens/creative_details.go +++ b/tg_bot/screens/creative_details.go @@ -3,6 +3,7 @@ package screens import ( "context" "fmt" + "sort" "strings" "github.com/NicoNex/echotron/v3" @@ -42,8 +43,19 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { } else { log.Info().Msg("Creative text is empty from backend") } - s.MediaType = creative.MediaType - s.MediaFileID = creative.MediaFileID + s.MediaItems = nil + if len(creative.MediaItems) > 0 { + mediaItems := append([]backend.CreativeMediaItem(nil), creative.MediaItems...) + sort.Slice(mediaItems, func(i, j int) bool { + return mediaItems[i].Position < mediaItems[j].Position + }) + for _, item := range mediaItems { + s.MediaItems = append(s.MediaItems, MediaItem{ + MediaType: item.MediaType, + MediaFileID: item.MediaFileID, + }) + } + } s.Buttons = nil for _, btn := range creative.Buttons { s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) @@ -92,8 +104,7 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { s.ShowMediaPanel(b) case data == "delete_media": - s.MediaType = "" - s.MediaFileID = "" + s.MediaItems = nil s.MediaChanged = true s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) @@ -166,10 +177,17 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { if !s.SetMediaFromMessage(u.Message) { return } - s.DeleteUserMessage(b, u.Message.ID) + if u.Message.MediaGroupID != "" { + groupID := u.Message.MediaGroupID + s.scheduleMediaGroupAction(groupID, func() { + s.UpdateCreativePreview(b) + s.ShowMediaPanel(b) + }) + b.MarkHandled() + return + } s.UpdateCreativePreview(b) - s.ClearInputMode() - s.showManagementPanelWithDelete(b) + s.ShowMediaPanel(b) return case inputModeButtonText: if u.Message.Text == "" { @@ -220,7 +238,6 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { } text := ui.FormatMessageHTML(u.Message) s.Text = &text - s.DeleteUserMessage(b, u.Message.ID) s.UpdateCreativePreview(b) @@ -233,7 +250,6 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message.Text != "" { text := ui.FormatMessageHTML(u.Message) s.Text = &text - s.DeleteUserMessage(b, u.Message.ID) s.UpdateCreativePreview(b) @@ -279,13 +295,8 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) { Text: s.Text, } if s.MediaChanged { - mediaType := s.MediaType - mediaFileID := s.MediaFileID - input.MediaType = &mediaType - input.MediaFileID = &mediaFileID - - if s.MediaFileID != "" { - mediaData, err := b.DownloadFileBytes(s.MediaFileID) + if len(s.MediaItems) > 0 { + mediaItems, err := s.BuildMediaInputs(b) if err != nil { log.Error().Err(err).Msg("Failed to download creative media") // Показываем ошибку через панель управления @@ -295,10 +306,10 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) { }) return } - input.MediaData = &mediaData + input.MediaItems = &mediaItems } else { - emptyData := []byte{} - input.MediaData = &emptyData + emptyItems := []backend.CreativeMediaInput{} + input.MediaItems = &emptyItems } } diff --git a/tg_bot/screens/creative_editor.go b/tg_bot/screens/creative_editor.go index 8d68378..e367dcf 100644 --- a/tg_bot/screens/creative_editor.go +++ b/tg_bot/screens/creative_editor.go @@ -4,8 +4,11 @@ import ( "fmt" "regexp" "strings" + "sync" + "time" "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" "github.com/rs/zerolog/log" @@ -17,6 +20,11 @@ type InlineButton struct { URL string } +type MediaItem struct { + MediaType string // "photo", "video", "animation" + MediaFileID string // File ID из Telegram +} + // CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива type CreativeEditorFields struct { // Данные креатива @@ -25,8 +33,7 @@ type CreativeEditorFields struct { Buttons []InlineButton // Медиа - MediaType string // "photo", "video", "animation" - MediaFileID string // File ID из Telegram + MediaItems []MediaItem MediaChanged bool // Режим ввода @@ -39,15 +46,20 @@ type CreativeEditorFields struct { CreativeMessageID *int // ID сообщения с панелью управления ControlPanelMessageID *int + // ID сообщений дополнительного медиа (после первого) + ExtraMediaMessageIDs []int + + pendingMediaGroupMu sync.Mutex + pendingMediaGroupID string + pendingMediaGroupTimer *time.Timer + pendingMediaGroupGen int64 // Предыдущее состояние для определения изменения типа сообщения - previousHasMedia bool - previousMediaType string - previousMediaFileID string + previousMediaItems []MediaItem } const inviteLinkPlaceholder = "{{invite_link}}" -const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра +const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра const buttonURLPlaceholder = "https://example.com" var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`) @@ -85,6 +97,77 @@ func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.I return keyboard } +func copyMediaItems(items []MediaItem) []MediaItem { + if len(items) == 0 { + return nil + } + clone := make([]MediaItem, len(items)) + copy(clone, items) + return clone +} + +func mediaItemsEqual(a, b []MediaItem) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].MediaType != b[i].MediaType || a[i].MediaFileID != b[i].MediaFileID { + return false + } + } + return true +} + +func (e *CreativeEditorFields) primaryMedia() *MediaItem { + if len(e.MediaItems) == 0 { + return nil + } + return &e.MediaItems[0] +} + +func (e *CreativeEditorFields) hasMultipleMedia() bool { + return len(e.MediaItems) > 1 +} + +func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) { + for _, msgID := range e.ExtraMediaMessageIDs { + b.DeleteMessage(b.ChatID, msgID) + } + e.ExtraMediaMessageIDs = nil +} + +const mediaGroupDebounce = 2 * time.Second + +func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) { + e.pendingMediaGroupMu.Lock() + defer e.pendingMediaGroupMu.Unlock() + + if groupID == "" { + action() + return + } + + if e.pendingMediaGroupTimer != nil { + e.pendingMediaGroupTimer.Stop() + } + e.pendingMediaGroupID = groupID + e.pendingMediaGroupGen++ + gen := e.pendingMediaGroupGen + e.pendingMediaGroupTimer = time.AfterFunc(mediaGroupDebounce, func() { + e.pendingMediaGroupMu.Lock() + currentGroup := e.pendingMediaGroupID + currentGen := e.pendingMediaGroupGen + if currentGen == gen && currentGroup == groupID { + e.pendingMediaGroupID = "" + e.pendingMediaGroupTimer = nil + } + e.pendingMediaGroupMu.Unlock() + if currentGen == gen && currentGroup == groupID { + action() + } + }) +} + func isTelegramParseError(err error) bool { if err == nil { return false @@ -188,14 +271,66 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { } // Если есть медиа, отправляем с медиа - if e.MediaFileID != "" { + if len(e.MediaItems) > 1 { + e.clearExtraMediaMessages(b) + group := make([]echotron.GroupableInputMedia, 0, len(e.MediaItems)) + for i, item := range e.MediaItems { + caption := "" + parseMode := echotron.ParseMode("") + if i == 0 { + caption = creativeText + parseMode = echotron.HTML + } + switch item.MediaType { + case "photo": + group = append(group, echotron.InputMediaPhoto{ + Type: echotron.MediaTypePhoto, + Media: echotron.NewInputFileID(item.MediaFileID), + Caption: caption, + ParseMode: parseMode, + }) + case "video": + group = append(group, echotron.InputMediaVideo{ + Type: echotron.MediaTypeVideo, + Media: echotron.NewInputFileID(item.MediaFileID), + Caption: caption, + ParseMode: parseMode, + }) + case "animation": + group = append(group, echotron.InputMediaVideo{ + Type: echotron.MediaTypeVideo, + Media: echotron.NewInputFileID(item.MediaFileID), + Caption: caption, + ParseMode: parseMode, + }) + default: + log.Warn().Str("media_type", item.MediaType).Msg("Unsupported media type for media group") + } + } + + if len(group) > 0 { + res, err := b.SendMediaGroup(b.ChatID, group, nil) + if err != nil { + log.Error().Err(err).Msg("Failed to send media group") + if id, err := sendText(); err == nil { + msgID = id + } else if isTelegramParseError(err) { + if id, err := sendFallback(); err == nil { + msgID = id + } + } + } else if len(res.Result) > 0 { + msgID = res.Result[0].ID + } + } + } else if media := e.primaryMedia(); media != nil { var res echotron.APIResponseMessage var err error - switch e.MediaType { + switch media.MediaType { case "photo": res, err = b.SendPhoto( - echotron.NewInputFileID(e.MediaFileID), + echotron.NewInputFileID(media.MediaFileID), b.ChatID, &echotron.PhotoOptions{ Caption: creativeText, @@ -205,7 +340,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { ) case "video": res, err = b.SendVideo( - echotron.NewInputFileID(e.MediaFileID), + echotron.NewInputFileID(media.MediaFileID), b.ChatID, &echotron.VideoOptions{ Caption: creativeText, @@ -215,7 +350,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { ) case "animation": res, err = b.SendAnimation( - echotron.NewInputFileID(e.MediaFileID), + echotron.NewInputFileID(media.MediaFileID), b.ChatID, &echotron.AnimationOptions{ Caption: creativeText, @@ -226,7 +361,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { } if err != nil { - log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media") + log.Error().Err(err).Str("media_type", media.MediaType).Msg("Failed to send media") // Fallback to text message - используем прямой SendMessage if id, err := sendText(); err == nil { msgID = id @@ -238,6 +373,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { } else if res.Result != nil { msgID = res.Result.ID } + } else { // Отправляем обычное текстовое сообщение напрямую (не через SendNew) // чтобы не затронуть LastMessageID и не затронуть cleanup @@ -262,9 +398,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! e.CreativeMessageID = &msgID - e.previousHasMedia = e.MediaFileID != "" - e.previousMediaType = e.MediaType - e.previousMediaFileID = e.MediaFileID + e.previousMediaItems = copyMediaItems(e.MediaItems) } // UpdateCreativePreview обновляет превью креатива @@ -276,15 +410,18 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { return } - currentHasMedia := e.MediaFileID != "" + currentHasMedia := len(e.MediaItems) > 0 + previousHasMedia := len(e.previousMediaItems) > 0 + mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems) // Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения - if currentHasMedia != e.previousHasMedia { + if currentHasMedia != previousHasMedia { // Удаляем оба сообщения if e.CreativeMessageID != nil { b.DeleteMessage(b.ChatID, *e.CreativeMessageID) e.CreativeMessageID = nil } + e.clearExtraMediaMessages(b) if e.ControlPanelMessageID != nil { b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID) e.ControlPanelMessageID = nil @@ -295,6 +432,16 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { return } + if mediaItemsChanged && (len(e.MediaItems) > 1 || len(e.previousMediaItems) > 1) { + if e.CreativeMessageID != nil { + b.DeleteMessage(b.ChatID, *e.CreativeMessageID) + e.CreativeMessageID = nil + } + e.clearExtraMediaMessages(b) + e.SendCreativePreview(b) + return + } + // Тип не изменился - просто редактируем на месте creativeText := e.GetCreativeText() creativeButtons := e.GetCreativeButtons() @@ -308,10 +455,14 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { if len(runes) > 200 { textPreview = string(runes[:200]) + "..." } + mediaType := "" + if media := e.primaryMedia(); media != nil { + mediaType = media.MediaType + } log.Error(). Err(err). Int("creative_message_id", *e.CreativeMessageID). - Str("media_type", e.MediaType). + Str("media_type", mediaType). Int("text_len", len([]rune(creativeText))). Str("text_preview", textPreview). Msg(msg) @@ -319,38 +470,42 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { // Если есть медиа - редактируем caption updated := false - if e.MediaFileID != "" { - mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType + if media := e.primaryMedia(); media != nil { + var prevMedia MediaItem + if len(e.previousMediaItems) > 0 { + prevMedia = e.previousMediaItems[0] + } + mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType if mediaChanged { - var media echotron.InputMedia - switch e.MediaType { + var inputMedia echotron.InputMedia + switch media.MediaType { case "photo": - media = echotron.InputMediaPhoto{ + inputMedia = echotron.InputMediaPhoto{ Type: echotron.MediaTypePhoto, - Media: echotron.NewInputFileID(e.MediaFileID), + Media: echotron.NewInputFileID(media.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } case "video": - media = echotron.InputMediaVideo{ + inputMedia = echotron.InputMediaVideo{ Type: echotron.MediaTypeVideo, - Media: echotron.NewInputFileID(e.MediaFileID), + Media: echotron.NewInputFileID(media.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } case "animation": - media = echotron.InputMediaAnimation{ + inputMedia = echotron.InputMediaAnimation{ Type: echotron.MediaTypeAnimation, - Media: echotron.NewInputFileID(e.MediaFileID), + Media: echotron.NewInputFileID(media.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } } - if media != nil { + if inputMedia != nil { _, err := b.EditMessageMedia( msgID, - media, + inputMedia, &echotron.MessageMediaOptions{ ReplyMarkup: keyboard, }, @@ -361,7 +516,7 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { updated = true } } else { - log.Warn().Str("media_type", e.MediaType).Msg("Unsupported media type for edit") + log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit") } } else { _, err := b.EditMessageCaption( @@ -399,9 +554,11 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { } if updated { - e.previousHasMedia = currentHasMedia - e.previousMediaType = e.MediaType - e.previousMediaFileID = e.MediaFileID + if currentHasMedia { + e.previousMediaItems = copyMediaItems(e.MediaItems) + } else { + e.previousMediaItems = nil + } } } @@ -413,21 +570,23 @@ func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) { } func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool { + var item MediaItem switch { case message.Photo != nil && len(message.Photo) > 0: photo := message.Photo[len(message.Photo)-1] - e.MediaType = "photo" - e.MediaFileID = photo.FileID + item.MediaType = "photo" + item.MediaFileID = photo.FileID case message.Video != nil: - e.MediaType = "video" - e.MediaFileID = message.Video.FileID + item.MediaType = "video" + item.MediaFileID = message.Video.FileID case message.Animation != nil: - e.MediaType = "animation" - e.MediaFileID = message.Animation.FileID + item.MediaType = "animation" + item.MediaFileID = message.Animation.FileID default: return false } + e.MediaItems = append(e.MediaItems, item) e.MediaChanged = true return true } @@ -496,13 +655,21 @@ func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, Button("✎ Текст", "edit_text"), Button("+ Медиа", "add_media"), )) - buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button"))) + addButtonLabel := "+ Добавить кнопку" + addButtonCallback := "add_button" + if e.hasMultipleMedia() { + addButtonLabel = "Кнопки недоступны для >1 медиа" + addButtonCallback = "empty" + } + buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback))) // Кнопка удаления медиа (если медиа добавлено) - if e.MediaFileID != "" { - buttons = append(buttons, Row( - Button("⌫ Убрать медиа", "delete_media"), - )) + if len(e.MediaItems) > 0 { + label := "⌫ Убрать медиа" + if len(e.MediaItems) > 1 { + label = fmt.Sprintf("⌫ Убрать медиа (%d)", len(e.MediaItems)) + } + buttons = append(buttons, Row(Button(label, "delete_media"))) } // Кнопки удаления (если есть кнопки) @@ -566,3 +733,25 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons b.Edit(text, keyboard) } } + +func (e *CreativeEditorFields) BuildMediaInputs(b *bot.Bot) ([]backend.CreativeMediaInput, error) { + if len(e.MediaItems) == 0 { + return nil, nil + } + mediaItems := make([]backend.CreativeMediaInput, 0, len(e.MediaItems)) + for _, item := range e.MediaItems { + if item.MediaFileID == "" { + continue + } + mediaData, err := b.DownloadFileBytes(item.MediaFileID) + if err != nil { + return nil, err + } + mediaItems = append(mediaItems, backend.CreativeMediaInput{ + MediaType: item.MediaType, + MediaFileID: item.MediaFileID, + MediaData: mediaData, + }) + } + return mediaItems, nil +} diff --git a/tg_bot/screens/creative_editor_ui.go b/tg_bot/screens/creative_editor_ui.go index 1f53a13..a6a0736 100644 --- a/tg_bot/screens/creative_editor_ui.go +++ b/tg_bot/screens/creative_editor_ui.go @@ -35,7 +35,8 @@ const msgAddMediaPrompt = ` Отправьте фото, видео или GIF -Это медиа будет отображаться в креативе вместе с текстом +Можно добавить несколько медиа — отправляйте по одному. +Нажмите «Отмена», когда закончите. ` const msgButtonURLPrompt = `