package screens import ( "fmt" "regexp" "strings" "github.com/NicoNex/echotron/v3" "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 } // CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива type CreativeEditorFields struct { // Данные креатива Name *string Text *string Buttons []InlineButton // Медиа MediaType string // "photo", "video", "animation" MediaFileID string // File ID из Telegram MediaChanged bool // Режим ввода InputMode string // Состояние для добавления кнопки PendingButtonType string // ID сообщения с креативом (которое постоянно обновляется) CreativeMessageID *int // ID сообщения с панелью управления ControlPanelMessageID *int // Предыдущее состояние для определения изменения типа сообщения previousHasMedia bool previousMediaType string previousMediaFileID string } const inviteLinkPlaceholder = "{{invite_link}}" const inviteLinkPreviewURL = "https://t.me/+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 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 e.MediaFileID != "" { var res echotron.APIResponseMessage var err error switch e.MediaType { case "photo": res, err = b.SendPhoto( echotron.NewInputFileID(e.MediaFileID), b.ChatID, &echotron.PhotoOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) case "video": res, err = b.SendVideo( echotron.NewInputFileID(e.MediaFileID), b.ChatID, &echotron.VideoOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) case "animation": res, err = b.SendAnimation( echotron.NewInputFileID(e.MediaFileID), b.ChatID, &echotron.AnimationOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) } if err != nil { log.Error().Err(err).Str("media_type", e.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.previousHasMedia = e.MediaFileID != "" e.previousMediaType = e.MediaType e.previousMediaFileID = e.MediaFileID } // UpdateCreativePreview обновляет превью креатива func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { if e.CreativeMessageID == nil { log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil") // Если превью еще не создано, создаем его e.SendCreativePreview(b) return } currentHasMedia := e.MediaFileID != "" // Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения if currentHasMedia != e.previousHasMedia { // Удаляем оба сообщения if e.CreativeMessageID != nil { b.DeleteMessage(b.ChatID, *e.CreativeMessageID) e.CreativeMessageID = nil } if e.ControlPanelMessageID != nil { b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID) e.ControlPanelMessageID = nil } // Создаем превью заново (панель будет создана через ShowManagementPanel после этого) 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]) + "..." } log.Error(). Err(err). Int("creative_message_id", *e.CreativeMessageID). Str("media_type", e.MediaType). Int("text_len", len([]rune(creativeText))). Str("text_preview", textPreview). Msg(msg) } // Если есть медиа - редактируем caption updated := false if e.MediaFileID != "" { mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType if mediaChanged { var media echotron.InputMedia switch e.MediaType { case "photo": media = echotron.InputMediaPhoto{ Type: echotron.MediaTypePhoto, Media: echotron.NewInputFileID(e.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } case "video": media = echotron.InputMediaVideo{ Type: echotron.MediaTypeVideo, Media: echotron.NewInputFileID(e.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } case "animation": media = echotron.InputMediaAnimation{ Type: echotron.MediaTypeAnimation, Media: echotron.NewInputFileID(e.MediaFileID), Caption: creativeText, ParseMode: echotron.HTML, } } if media != nil { _, err := b.EditMessageMedia( msgID, media, &echotron.MessageMediaOptions{ ReplyMarkup: keyboard, }, ) if err != nil { logEditError("Failed to edit message media", err) } else { updated = true } } else { log.Warn().Str("media_type", e.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 { e.previousHasMedia = currentHasMedia e.previousMediaType = e.MediaType e.previousMediaFileID = e.MediaFileID } } 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 { switch { case message.Photo != nil && len(message.Photo) > 0: photo := message.Photo[len(message.Photo)-1] e.MediaType = "photo" e.MediaFileID = photo.FileID case message.Video != nil: e.MediaType = "video" e.MediaFileID = message.Video.FileID case message.Animation != nil: e.MediaType = "animation" e.MediaFileID = message.Animation.FileID default: return false } 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"), )) buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button"))) // Кнопка удаления медиа (если медиа добавлено) if e.MediaFileID != "" { buttons = append(buttons, Row( Button("⌫ Убрать медиа", "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) } }