package screens 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" ) // InlineButton представляет кнопку с URL type InlineButton struct { Text string URL string } type MediaItem struct { MediaType string // "photo", "video", "animation" MediaFileID string // File ID из Telegram } // CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива type CreativeEditorFields struct { // Данные креатива Name *string Text *string Buttons []InlineButton Tag *string // "testing" or "production" // Медиа MediaItems []MediaItem MediaChanged bool // Режим ввода InputMode string // Состояние для добавления кнопки PendingButtonType string // ID сообщения с креативом (которое постоянно обновляется) CreativeMessageID *int // ID сообщения с панелью управления ControlPanelMessageID *int // ID сообщений дополнительного медиа (после первого) ExtraMediaMessageIDs []int pendingMediaGroupMu sync.Mutex pendingMediaGroupID string pendingMediaGroupTimer *time.Timer pendingMediaGroupGen int64 // Предыдущее состояние для определения изменения типа сообщения previousMediaItems []MediaItem } const inviteLinkPlaceholder = "{{invite_link}}" const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра const buttonURLPlaceholder = "https://example.com" var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`) var inviteLinkTagPattern = regexp.MustCompile(`\s*(.*?)\s*`) var htmlTagPattern = regexp.MustCompile(`<[^>]+>`) const ( inputModeText = "text" inputModeButtonText = "button_text" inputModeButtonURL = "button_url" inputModeMedia = "media" ) const msgCreativeDefault = ` 💭 Я твой креатив У меня пока нет текста, добавь его ниже. ` const msgCreativeManagement = ` 👆Превью креатива выше — так он будет выглядеть при отправке. Выберите, что изменить: ` const msgCreativePreviewBroken = "⚠️ Не удалось показать превью: креатив содержит некорректную разметку. Создайте его заново или отредактируйте текст." func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup { keyboard := echotron.InlineKeyboardMarkup{ InlineKeyboard: buttons, } if len(buttons) == 0 { keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} } 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 } msg := err.Error() return strings.Contains(msg, "can't parse entities") || strings.Contains(msg, "Unexpected end tag") } // GetCreativeText возвращает текст для превью креатива func (e *CreativeEditorFields) GetCreativeText() string { if e.Text == nil { return msgCreativeDefault } text := strings.TrimSpace(ui.SanitizeHTML(*e.Text)) if text == "" { return msgCreativeDefault } // Заменяем плейсхолдеры tg-link на кликабельную preview-ссылку. text = inviteLinkTagPattern.ReplaceAllStringFunc(text, func(match string) string { sub := inviteLinkTagPattern.FindStringSubmatch(match) inner := "" if len(sub) > 1 { inner = strings.TrimSpace(htmlTagPattern.ReplaceAllString(sub[1], "")) } if inner == "" { inner = inviteLinkPreviewURL } return fmt.Sprintf(`%s`, inviteLinkPreviewURL, inner) }) // Заменяем пригласительные ссылки на preview URL только для отображения. text = inviteLinkPattern.ReplaceAllString(text, inviteLinkPreviewURL) // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках). text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL) return text } // GetCreativeButtons возвращает кнопки для превью креатива func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton { var buttons [][]echotron.InlineKeyboardButton for _, btn := range e.Buttons { url := btn.URL if url == inviteLinkPlaceholder { url = inviteLinkPreviewURL } buttons = append(buttons, []echotron.InlineKeyboardButton{ { Text: btn.Text, URL: url, }, }) } return buttons } // SendCreativePreview отправляет превью креатива func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { creativeText := e.GetCreativeText() creativeButtons := e.GetCreativeButtons() // Создаем клавиатуру с пустым массивом (не nil!) keyboard := buildCreativeKeyboard(creativeButtons) var msgID int sendText := func() (int, error) { res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ ReplyMarkup: keyboard, ParseMode: echotron.HTML, LinkPreviewOptions: echotron.LinkPreviewOptions{ IsDisabled: true, }, }) if err != nil { return 0, err } if res.Result == nil { return 0, fmt.Errorf("send message: empty result") } return res.Result.ID, nil } sendFallback := func() (int, error) { res, err := b.SendMessage(msgCreativePreviewBroken, b.ChatID, &echotron.MessageOptions{ ReplyMarkup: keyboard, LinkPreviewOptions: echotron.LinkPreviewOptions{ IsDisabled: true, }, }) if err != nil { return 0, err } if res.Result == nil { return 0, fmt.Errorf("send message: empty result") } return res.Result.ID, nil } // Если есть медиа, отправляем с медиа 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 media.MediaType { case "photo": res, err = b.SendPhoto( echotron.NewInputFileID(media.MediaFileID), b.ChatID, &echotron.PhotoOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) case "video": res, err = b.SendVideo( echotron.NewInputFileID(media.MediaFileID), b.ChatID, &echotron.VideoOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) case "animation": res, err = b.SendAnimation( echotron.NewInputFileID(media.MediaFileID), b.ChatID, &echotron.AnimationOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) } if err != nil { 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 } else if isTelegramParseError(err) { if id, err := sendFallback(); err == nil { msgID = id } } } else if res.Result != nil { msgID = res.Result.ID } } else { // Отправляем обычное текстовое сообщение напрямую (не через SendNew) // чтобы не затронуть LastMessageID и не затронуть cleanup id, err := sendText() if err != nil { log.Error().Err(err).Msg("Failed to send creative preview") if isTelegramParseError(err) { id, err = sendFallback() if err == nil { msgID = id } else { log.Error().Err(err).Msg("Failed to send fallback creative preview") return } } else { return } } else { msgID = id } } // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! e.CreativeMessageID = &msgID e.previousMediaItems = copyMediaItems(e.MediaItems) // Устанавливаем LastMessageID чтобы ShowControlPanel мог корректно работать // ВАЖНО: Это позволяет избежать конфликтов при навигации между экранами b.LastMessageID = msgID } // UpdateCreativePreview обновляет превью креатива func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { if e.CreativeMessageID == nil { log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil") // Если превью еще не создано, создаем его e.SendCreativePreview(b) return } currentHasMedia := len(e.MediaItems) > 0 previousHasMedia := len(e.previousMediaItems) > 0 mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems) // Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения 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 } // Создаем превью заново (панель будет создана через ShowManagementPanel после этого) e.SendCreativePreview(b) 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() keyboard := buildCreativeKeyboard(creativeButtons) msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID) logEditError := func(msg string, err error) { textPreview := creativeText runes := []rune(textPreview) 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", mediaType). Int("text_len", len([]rune(creativeText))). Str("text_preview", textPreview). Msg(msg) } // Если есть медиа - редактируем caption updated := false 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 inputMedia echotron.InputMedia switch media.MediaType { case "photo": inputMedia = echotron.InputMediaPhoto{ Type: echotron.MediaTypePhoto, Media: echotron.NewInputFileID(media.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } case "video": inputMedia = echotron.InputMediaVideo{ Type: echotron.MediaTypeVideo, Media: echotron.NewInputFileID(media.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } case "animation": inputMedia = echotron.InputMediaAnimation{ Type: echotron.MediaTypeAnimation, Media: echotron.NewInputFileID(media.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } } if inputMedia != nil { _, err := b.EditMessageMedia( msgID, inputMedia, &echotron.MessageMediaOptions{ ReplyMarkup: keyboard, }, ) if err != nil { logEditError("Failed to edit message media", err) } else { updated = true } } else { log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit") } } else { _, err := b.EditMessageCaption( msgID, &echotron.MessageCaptionOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) if err != nil { logEditError("Failed to edit media caption", err) } else { updated = true } } } else { // Текстовое сообщение - редактируем текст _, err := b.EditMessageText( creativeText, msgID, &echotron.MessageTextOptions{ ParseMode: echotron.HTML, ReplyMarkup: keyboard, LinkPreviewOptions: echotron.LinkPreviewOptions{ IsDisabled: true, }, }, ) if err != nil { logEditError("Failed to edit message text", err) } else { updated = true } } if updated { if currentHasMedia { e.previousMediaItems = copyMediaItems(e.MediaItems) } else { e.previousMediaItems = nil } } } func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) { _, err := b.DeleteMessage(b.ChatID, messageID) if err != nil { log.Error().Err(err).Msg("Failed to delete user message") } } 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] item.MediaType = "photo" item.MediaFileID = photo.FileID case message.Video != nil: item.MediaType = "video" item.MediaFileID = message.Video.FileID case message.Animation != nil: item.MediaType = "animation" item.MediaFileID = message.Animation.FileID default: return false } e.MediaItems = append(e.MediaItems, item) e.MediaChanged = true return true } func (e *CreativeEditorFields) AddInviteButton(text string) { e.Buttons = append(e.Buttons, InlineButton{ Text: text, URL: inviteLinkPlaceholder, }) } func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) { e.Buttons = append(e.Buttons, InlineButton{ Text: text, URL: buttonURLPlaceholder, }) } func (e *CreativeEditorFields) UpdateLastButtonURL(url string) { if len(e.Buttons) > 0 { e.Buttons[len(e.Buttons)-1].URL = url } } func (e *CreativeEditorFields) ClearInputMode() { e.InputMode = "" e.PendingButtonType = "" } func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) { if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 { lastButton := e.Buttons[len(e.Buttons)-1] if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder { e.Buttons = e.Buttons[:len(e.Buttons)-1] e.UpdateCreativePreview(b) } } e.ClearInputMode() } func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool { if allowHTTP { return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") } return strings.HasPrefix(url, "https://") } // ShowManagementPanel показывает панель управления креативом // confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить") // confirmCallback - callback для кнопки подтверждения // cancelCallback - callback для кнопки отмены // extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) { text := msgCreativeManagement buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...) e.ShowControlPanel(b, text, buttons) } func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton { var buttons [][]echotron.InlineKeyboardButton // Блок редактирования контента buttons = append(buttons, Row( Button("✎ Текст", "edit_text"), Button("+ Медиа", "add_media"), )) addButtonLabel := "+ Добавить кнопку" addButtonCallback := "add_button" if e.hasMultipleMedia() { addButtonLabel = "Кнопки недоступны для >1 медиа" addButtonCallback = "empty" } buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback))) // Кнопка удаления медиа (если медиа добавлено) 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"))) } // Кнопки удаления (если есть кнопки) if len(e.Buttons) > 0 { var row []echotron.InlineKeyboardButton for i, btn := range e.Buttons { row = append(row, Button( fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text), fmt.Sprintf("delete_button:%d", i), )) if len(row) == 2 { buttons = append(buttons, row) row = nil } } if len(row) > 0 { buttons = append(buttons, row) } } // Дополнительные кнопки (например "Удалить креатив") for _, extraRow := range extraButtons { buttons = append(buttons, extraRow) } // Финальные кнопки cancelLabel := "← " + cancelText if e.Text != nil { buttons = append(buttons, Row( Button(cancelLabel, cancelCallback), Button(confirmText, confirmCallback), )) } else { buttons = append(buttons, Row( Button(cancelLabel, cancelCallback), )) } return buttons } // ShowControlPanel редактирует панель управления с произвольным текстом и кнопками func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) { if e.ControlPanelMessageID == nil { // Отправляем новую панель keyboard := Keyboard(buttons...) b.SendNew(text, keyboard) // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! controlPanelMsgID := b.LastMessageID e.ControlPanelMessageID = &controlPanelMsgID } else { // Редактируем существующую панель // Временно заменяем LastMessageID на ID панели управления oldLastMessageID := b.LastMessageID b.LastMessageID = *e.ControlPanelMessageID defer func() { b.LastMessageID = oldLastMessageID }() keyboard := Keyboard(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 }