бот несколько медиа

This commit is contained in:
Artem Tsyrulnikov
2026-01-23 16:04:28 +03:00
parent 912b543f75
commit b5d839ddae
5 changed files with 350 additions and 90 deletions

View File

@@ -341,6 +341,7 @@ func (b *Bot) Update(u *echotron.Update) {
log.Error().Err(err).Msg("SendMessage failed while sending restart notice") log.Error().Err(err).Msg("SendMessage failed while sending restart notice")
} }
b.SetState(b.commandRouter("/start"), EditMessage) b.SetState(b.commandRouter("/start"), EditMessage)
return
} }
// === Обработка входящего события === // === Обработка входящего события ===

View File

@@ -19,6 +19,7 @@ type AddCreativeCtx struct {
ProjectID string ProjectID string
BackState bot.State BackState bot.State
WaitMessageSent bool
} }
type AddCreativeStart struct{ Ctx *AddCreativeCtx } type AddCreativeStart struct{ Ctx *AddCreativeCtx }
@@ -40,9 +41,9 @@ const msgWaitCreative = `
const msgConfirmCreativeFormat = ` const msgConfirmCreativeFormat = `
<b> Добавляем этот креатив?</b> <b> Добавляем этот креатив?</b>
<b>Название креатива:</b> %s <b>Название:</b> %s
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i> <i>Чтобы изменить название, отправь мне сообщение 👇:</i>
` `
const msgDownloadMediaError = ` 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) { func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) {
if s.Ctx.WaitMessageSent {
return
}
kb := Keyboard(Row(Button("« Отмена", "cancel"))) kb := Keyboard(Row(Button("« Отмена", "cancel")))
b.Render(msgWaitCreative, kb, mode) b.Render(msgWaitCreative, kb, mode)
s.Ctx.WaitMessageSent = true
} }
func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) { 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.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) 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 s.ctx.InputMode = inputModeMedia
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
case data == "delete_media": case data == "delete_media":
s.ctx.MediaType = "" s.ctx.MediaItems = nil
s.ctx.MediaFileID = ""
s.ctx.MediaChanged = true s.ctx.MediaChanged = true
s.ctx.UpdateCreativePreview(b) s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage) 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) { 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 return
} }
@@ -284,10 +331,19 @@ func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) {
if !s.ctx.SetMediaFromMessage(u.Message) { if !s.ctx.SetMediaFromMessage(u.Message) {
return 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.UpdateCreativePreview(b)
s.ctx.ClearInputMode() s.ctx.ShowMediaPanel(b)
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
})
b.MarkHandled()
return
}
s.ctx.UpdateCreativePreview(b)
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) escapedName := ui.EscapeHTML(*s.Name)
confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName) confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName)
buttons := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel") buttons := filterCreateButtons(s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel"))
s.ShowControlPanel(b, confirmText, buttons) s.ShowControlPanel(b, confirmText, buttons)
} }
func (s *AddCreativeCtx) createCreative(b *bot.Bot) { func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
var err error var err error
var mediaData []byte var mediaItems []backend.CreativeMediaInput
if s.MediaFileID != "" { if len(s.MediaItems) > 0 {
mediaData, err = b.DownloadFileBytes(s.MediaFileID) mediaItems, err = s.BuildMediaInputs(b)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to download creative media") 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) s.ShowControlPanel(b, msgDownloadMediaError, buttons)
return return
} }
@@ -372,14 +428,16 @@ func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
log.Info().Str("text", *s.Text).Msg("Creative text") 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{ input := backend.CreateCreativeInput{
Name: *s.Name, Name: *s.Name,
Text: *s.Text, Text: *s.Text,
MediaType: s.MediaType,
MediaFileID: s.MediaFileID,
MediaData: mediaData,
Buttons: buttons, 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 { if err != nil {
log.Error().Err(err).Msg("Failed to create creative") log.Error().Err(err).Msg("Failed to create creative")
@@ -395,7 +453,7 @@ func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
userMsg = msgCreateError userMsg = msgCreateError
} }
buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel") buttons := filterCreateButtons(s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel"))
s.ShowControlPanel(b, userMsg, buttons) s.ShowControlPanel(b, userMsg, buttons)
return return
} }

View File

@@ -3,6 +3,7 @@ package screens
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"strings" "strings"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
@@ -42,8 +43,19 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
} else { } else {
log.Info().Msg("Creative text is empty from backend") log.Info().Msg("Creative text is empty from backend")
} }
s.MediaType = creative.MediaType s.MediaItems = nil
s.MediaFileID = creative.MediaFileID 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 s.Buttons = nil
for _, btn := range creative.Buttons { for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) 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) s.ShowMediaPanel(b)
case data == "delete_media": case data == "delete_media":
s.MediaType = "" s.MediaItems = nil
s.MediaFileID = ""
s.MediaChanged = true s.MediaChanged = true
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b) s.showManagementPanelWithDelete(b)
@@ -166,10 +177,17 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
if !s.SetMediaFromMessage(u.Message) { if !s.SetMediaFromMessage(u.Message) {
return return
} }
s.DeleteUserMessage(b, u.Message.ID) if u.Message.MediaGroupID != "" {
groupID := u.Message.MediaGroupID
s.scheduleMediaGroupAction(groupID, func() {
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
s.ClearInputMode() s.ShowMediaPanel(b)
s.showManagementPanelWithDelete(b) })
b.MarkHandled()
return
}
s.UpdateCreativePreview(b)
s.ShowMediaPanel(b)
return return
case inputModeButtonText: case inputModeButtonText:
if u.Message.Text == "" { if u.Message.Text == "" {
@@ -220,7 +238,6 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
} }
text := ui.FormatMessageHTML(u.Message) text := ui.FormatMessageHTML(u.Message)
s.Text = &text s.Text = &text
s.DeleteUserMessage(b, u.Message.ID) s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
@@ -233,7 +250,6 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message.Text != "" { if u.Message.Text != "" {
text := ui.FormatMessageHTML(u.Message) text := ui.FormatMessageHTML(u.Message)
s.Text = &text s.Text = &text
s.DeleteUserMessage(b, u.Message.ID) s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b) s.UpdateCreativePreview(b)
@@ -279,13 +295,8 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
Text: s.Text, Text: s.Text,
} }
if s.MediaChanged { if s.MediaChanged {
mediaType := s.MediaType if len(s.MediaItems) > 0 {
mediaFileID := s.MediaFileID mediaItems, err := s.BuildMediaInputs(b)
input.MediaType = &mediaType
input.MediaFileID = &mediaFileID
if s.MediaFileID != "" {
mediaData, err := b.DownloadFileBytes(s.MediaFileID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to download creative media") log.Error().Err(err).Msg("Failed to download creative media")
// Показываем ошибку через панель управления // Показываем ошибку через панель управления
@@ -295,10 +306,10 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
}) })
return return
} }
input.MediaData = &mediaData input.MediaItems = &mediaItems
} else { } else {
emptyData := []byte{} emptyItems := []backend.CreativeMediaInput{}
input.MediaData = &emptyData input.MediaItems = &emptyItems
} }
} }

View File

@@ -4,8 +4,11 @@ import (
"fmt" "fmt"
"regexp" "regexp"
"strings" "strings"
"sync"
"time"
"github.com/NicoNex/echotron/v3" "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/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -17,6 +20,11 @@ type InlineButton struct {
URL string URL string
} }
type MediaItem struct {
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
}
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива // CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
type CreativeEditorFields struct { type CreativeEditorFields struct {
// Данные креатива // Данные креатива
@@ -25,8 +33,7 @@ type CreativeEditorFields struct {
Buttons []InlineButton Buttons []InlineButton
// Медиа // Медиа
MediaType string // "photo", "video", "animation" MediaItems []MediaItem
MediaFileID string // File ID из Telegram
MediaChanged bool MediaChanged bool
// Режим ввода // Режим ввода
@@ -39,15 +46,20 @@ type CreativeEditorFields struct {
CreativeMessageID *int CreativeMessageID *int
// ID сообщения с панелью управления // ID сообщения с панелью управления
ControlPanelMessageID *int ControlPanelMessageID *int
// ID сообщений дополнительного медиа (после первого)
ExtraMediaMessageIDs []int
pendingMediaGroupMu sync.Mutex
pendingMediaGroupID string
pendingMediaGroupTimer *time.Timer
pendingMediaGroupGen int64
// Предыдущее состояние для определения изменения типа сообщения // Предыдущее состояние для определения изменения типа сообщения
previousHasMedia bool previousMediaItems []MediaItem
previousMediaType string
previousMediaFileID string
} }
const inviteLinkPlaceholder = "{{invite_link}}" const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
const buttonURLPlaceholder = "https://example.com" const buttonURLPlaceholder = "https://example.com"
var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`) 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 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 { func isTelegramParseError(err error) bool {
if err == nil { if err == nil {
return false 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 res echotron.APIResponseMessage
var err error var err error
switch e.MediaType { switch media.MediaType {
case "photo": case "photo":
res, err = b.SendPhoto( res, err = b.SendPhoto(
echotron.NewInputFileID(e.MediaFileID), echotron.NewInputFileID(media.MediaFileID),
b.ChatID, b.ChatID,
&echotron.PhotoOptions{ &echotron.PhotoOptions{
Caption: creativeText, Caption: creativeText,
@@ -205,7 +340,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
) )
case "video": case "video":
res, err = b.SendVideo( res, err = b.SendVideo(
echotron.NewInputFileID(e.MediaFileID), echotron.NewInputFileID(media.MediaFileID),
b.ChatID, b.ChatID,
&echotron.VideoOptions{ &echotron.VideoOptions{
Caption: creativeText, Caption: creativeText,
@@ -215,7 +350,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
) )
case "animation": case "animation":
res, err = b.SendAnimation( res, err = b.SendAnimation(
echotron.NewInputFileID(e.MediaFileID), echotron.NewInputFileID(media.MediaFileID),
b.ChatID, b.ChatID,
&echotron.AnimationOptions{ &echotron.AnimationOptions{
Caption: creativeText, Caption: creativeText,
@@ -226,7 +361,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
} }
if err != nil { 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 // Fallback to text message - используем прямой SendMessage
if id, err := sendText(); err == nil { if id, err := sendText(); err == nil {
msgID = id msgID = id
@@ -238,6 +373,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
} else if res.Result != nil { } else if res.Result != nil {
msgID = res.Result.ID msgID = res.Result.ID
} }
} else { } else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew) // Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не затронуть LastMessageID и не затронуть cleanup // чтобы не затронуть LastMessageID и не затронуть cleanup
@@ -262,9 +398,7 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную! // ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
e.CreativeMessageID = &msgID e.CreativeMessageID = &msgID
e.previousHasMedia = e.MediaFileID != "" e.previousMediaItems = copyMediaItems(e.MediaItems)
e.previousMediaType = e.MediaType
e.previousMediaFileID = e.MediaFileID
} }
// UpdateCreativePreview обновляет превью креатива // UpdateCreativePreview обновляет превью креатива
@@ -276,15 +410,18 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
return 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 { if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID) b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil e.CreativeMessageID = nil
} }
e.clearExtraMediaMessages(b)
if e.ControlPanelMessageID != nil { if e.ControlPanelMessageID != nil {
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID) b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
e.ControlPanelMessageID = nil e.ControlPanelMessageID = nil
@@ -295,6 +432,16 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
return 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() creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons() creativeButtons := e.GetCreativeButtons()
@@ -308,10 +455,14 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
if len(runes) > 200 { if len(runes) > 200 {
textPreview = string(runes[:200]) + "..." textPreview = string(runes[:200]) + "..."
} }
mediaType := ""
if media := e.primaryMedia(); media != nil {
mediaType = media.MediaType
}
log.Error(). log.Error().
Err(err). Err(err).
Int("creative_message_id", *e.CreativeMessageID). Int("creative_message_id", *e.CreativeMessageID).
Str("media_type", e.MediaType). Str("media_type", mediaType).
Int("text_len", len([]rune(creativeText))). Int("text_len", len([]rune(creativeText))).
Str("text_preview", textPreview). Str("text_preview", textPreview).
Msg(msg) Msg(msg)
@@ -319,38 +470,42 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
// Если есть медиа - редактируем caption // Если есть медиа - редактируем caption
updated := false updated := false
if e.MediaFileID != "" { if media := e.primaryMedia(); media != nil {
mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType var prevMedia MediaItem
if len(e.previousMediaItems) > 0 {
prevMedia = e.previousMediaItems[0]
}
mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType
if mediaChanged { if mediaChanged {
var media echotron.InputMedia var inputMedia echotron.InputMedia
switch e.MediaType { switch media.MediaType {
case "photo": case "photo":
media = echotron.InputMediaPhoto{ inputMedia = echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto, Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(e.MediaFileID), Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText, Caption: creativeText,
ParseMode: echotron.HTML, ParseMode: echotron.HTML,
} }
case "video": case "video":
media = echotron.InputMediaVideo{ inputMedia = echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo, Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(e.MediaFileID), Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText, Caption: creativeText,
ParseMode: echotron.HTML, ParseMode: echotron.HTML,
} }
case "animation": case "animation":
media = echotron.InputMediaAnimation{ inputMedia = echotron.InputMediaAnimation{
Type: echotron.MediaTypeAnimation, Type: echotron.MediaTypeAnimation,
Media: echotron.NewInputFileID(e.MediaFileID), Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText, Caption: creativeText,
ParseMode: echotron.HTML, ParseMode: echotron.HTML,
} }
} }
if media != nil { if inputMedia != nil {
_, err := b.EditMessageMedia( _, err := b.EditMessageMedia(
msgID, msgID,
media, inputMedia,
&echotron.MessageMediaOptions{ &echotron.MessageMediaOptions{
ReplyMarkup: keyboard, ReplyMarkup: keyboard,
}, },
@@ -361,7 +516,7 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
updated = true updated = true
} }
} else { } 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 { } else {
_, err := b.EditMessageCaption( _, err := b.EditMessageCaption(
@@ -399,9 +554,11 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
} }
if updated { if updated {
e.previousHasMedia = currentHasMedia if currentHasMedia {
e.previousMediaType = e.MediaType e.previousMediaItems = copyMediaItems(e.MediaItems)
e.previousMediaFileID = e.MediaFileID } 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 { func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool {
var item MediaItem
switch { switch {
case message.Photo != nil && len(message.Photo) > 0: case message.Photo != nil && len(message.Photo) > 0:
photo := message.Photo[len(message.Photo)-1] photo := message.Photo[len(message.Photo)-1]
e.MediaType = "photo" item.MediaType = "photo"
e.MediaFileID = photo.FileID item.MediaFileID = photo.FileID
case message.Video != nil: case message.Video != nil:
e.MediaType = "video" item.MediaType = "video"
e.MediaFileID = message.Video.FileID item.MediaFileID = message.Video.FileID
case message.Animation != nil: case message.Animation != nil:
e.MediaType = "animation" item.MediaType = "animation"
e.MediaFileID = message.Animation.FileID item.MediaFileID = message.Animation.FileID
default: default:
return false return false
} }
e.MediaItems = append(e.MediaItems, item)
e.MediaChanged = true e.MediaChanged = true
return true return true
} }
@@ -496,13 +655,21 @@ func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback,
Button("✎ Текст", "edit_text"), Button("✎ Текст", "edit_text"),
Button(" Медиа", "add_media"), 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 != "" { if len(e.MediaItems) > 0 {
buttons = append(buttons, Row( label := "⌫ Убрать медиа"
Button("⌫ Убрать медиа", "delete_media"), 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) 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
}

View File

@@ -35,7 +35,8 @@ const msgAddMediaPrompt = `
Отправьте <b>фото, видео или GIF</b> Отправьте <b>фото, видео или GIF</b>
<i>Это медиа будет отображаться в креативе вместе с текстом</i> <i>Можно добавить несколько медиа — отправляйте по одному.</i>
<i>Нажмите «Отмена», когда закончите.</i>
` `
const msgButtonURLPrompt = ` const msgButtonURLPrompt = `