package screens import ( "fmt" "sort" "strings" "example.com/m/bot" "github.com/NicoNex/echotron/v3" "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 // Состояние для добавления кнопки WaitingForButtonText bool WaitingForButtonURL bool CurrentButtonText *string WaitingForMedia bool PendingButtonType string // ID сообщения с креативом (которое постоянно обновляется) CreativeMessageID *int // ID сообщения с панелью управления ControlPanelMessageID *int // Предыдущее состояние для определения изменения типа сообщения previousHasMedia bool } const inviteLinkPlaceholder = "{{invite_link}}" const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра // GetCreativeText возвращает текст для превью креатива func (e *CreativeEditorFields) GetCreativeText() string { var result string if e.Text != nil && *e.Text != "" { text := strings.TrimSpace(*e.Text) // Проверяем что текст не пустой if text == "" { log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is empty, using default") return result + `💭 Я твой креатив У меня пока нет текста, добавь его ниже. ` } // Заменяем пригласительные ссылки с классом tg-link на preview-ссылку // Находим все вхождения ... и заменяем на кликабельную ссылку replacementCount := 0 for strings.Contains(text, ``) { startIdx := strings.Index(text, ``) if startIdx == -1 { break } endIdx := strings.Index(text[startIdx:], "") if endIdx == -1 { log.Warn().Msg("Found but no closing ") break } endIdx += startIdx // Извлекаем текст между тегами linkText := text[startIdx+len(``) : endIdx] log.Info(). Int("replacement_num", replacementCount). Str("linkText_before_sanitize", linkText). Msg("Processing tg-link") // ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности // Это нужно для старых креативов с неправильным HTML tagsRemoved := 0 for { openTag := strings.Index(linkText, "<") if openTag == -1 { break } closeTag := strings.Index(linkText[openTag:], ">") if closeTag == -1 { log.Warn().Str("linkText", linkText).Msg("Found < but no closing >") break } // Удаляем тег removedTag := linkText[openTag : openTag+closeTag+1] linkText = linkText[:openTag] + linkText[openTag+closeTag+1:] tagsRemoved++ log.Info(). Str("removed_tag", removedTag). Str("linkText_after", linkText). Int("tags_removed_total", tagsRemoved). Msg("Removed HTML tag from link text") } linkText = strings.TrimSpace(linkText) // Если текст ссылки не пустой, добавляем атрибут href с примером ссылки // Если пустой, показываем просто текст примера ссылки (без тега) var replacement string if linkText != "" { // Есть текст - добавляем href атрибут к тегу replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL, linkText) } else { // Пусто - показываем просто текст примера ссылки replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL) } log.Info(). Str("linkText_after_sanitize", linkText). Int("tags_removed", tagsRemoved). Str("replacement", replacement). Msg("Sanitized link text") text = text[:startIdx] + replacement + text[endIdx+len(""):] replacementCount++ } log.Info(). Int("replacements_made", replacementCount). Str("final_text", text). Msg("Finished processing tg-links") // Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках) text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL) log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text") return result + text } log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text") return result + `💭 Я твой креатив У меня пока нет текста, добавь его ниже. ` } // 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 := echotron.InlineKeyboardMarkup{ InlineKeyboard: creativeButtons, } if len(creativeButtons) == 0 { keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} } var msgID int // Если есть медиа, отправляем с медиа 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 res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ ReplyMarkup: keyboard, ParseMode: echotron.HTML, LinkPreviewOptions: echotron.LinkPreviewOptions{ IsDisabled: true, }, }) if err == nil && res.Result != nil { msgID = res.Result.ID } } else if res.Result != nil { msgID = res.Result.ID } } else { // Отправляем обычное текстовое сообщение напрямую (не через SendNew) // чтобы не затронуть LastMessageID и не затронуть cleanup res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ ReplyMarkup: keyboard, ParseMode: echotron.HTML, LinkPreviewOptions: echotron.LinkPreviewOptions{ IsDisabled: true, }, }) if err != nil { log.Error().Err(err).Msg("Failed to send creative preview") return } if res.Result != nil { msgID = res.Result.ID } } // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! e.CreativeMessageID = &msgID e.previousHasMedia = e.MediaFileID != "" log.Info().Int("creative_msg_id", *e.CreativeMessageID).Bool("has_media", e.MediaFileID != "").Msg("Creative preview sent") } // 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 != "" log.Info(). Int("creative_msg_id", *e.CreativeMessageID). Bool("has_media", currentHasMedia). Bool("previous_has_media", e.previousHasMedia). Str("media_type", e.MediaType). Msg("Updating creative preview") // Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения if currentHasMedia != e.previousHasMedia { log.Info().Msg("Message type changed, recreating both messages") // Удаляем оба сообщения 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 := echotron.InlineKeyboardMarkup{ InlineKeyboard: creativeButtons, } if len(creativeButtons) == 0 { keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} } msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID) // Если есть медиа - редактируем caption if e.MediaFileID != "" { _, err := b.EditMessageCaption( msgID, &echotron.MessageCaptionOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) if err != nil { log.Error().Err(err).Msg("Failed to edit media caption") } else { e.previousHasMedia = currentHasMedia } } else { // Текстовое сообщение - редактируем текст _, err := b.EditMessageText( creativeText, msgID, &echotron.MessageTextOptions{ ParseMode: echotron.HTML, ReplyMarkup: keyboard, LinkPreviewOptions: echotron.LinkPreviewOptions{ IsDisabled: true, }, }, ) if err != nil { log.Error().Err(err).Msg("Failed to edit message text") } else { e.previousHasMedia = currentHasMedia } } } // ShowManagementPanel показывает панель управления креативом // confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить") // confirmCallback - callback для кнопки подтверждения // cancelCallback - callback для кнопки отмены // extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) { text := `👆Превью креатива выше — так он будет выглядеть при отправке. Выберите, что изменить:` 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) } // Финальные кнопки if e.Text != nil { buttons = append(buttons, Row( Button("← Назад", cancelCallback), Button(confirmButtonText, confirmCallback), )) } else { buttons = append(buttons, Row( Button("← Назад", cancelCallback), )) } e.ShowControlPanel(b, text, 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 log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Control panel sent") } else { // Редактируем существующую панель log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel") // Временно заменяем LastMessageID на ID панели управления oldLastMessageID := b.LastMessageID b.LastMessageID = *e.ControlPanelMessageID keyboard := Keyboard(buttons...) b.Edit(text, keyboard) // Восстанавливаем оригинальный LastMessageID b.LastMessageID = oldLastMessageID log.Info().Msg("Control panel updated") } } // GetFormattedText извлекает форматированный текст из сообщения с entities func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) string { // Определяем откуда брать текст и entities var text string var entities []*echotron.MessageEntity if message.Text != "" { text = message.Text entities = message.Entities } else if message.Caption != "" { text = message.Caption entities = message.CaptionEntities } if text == "" { return "" } // Если нет entities - возвращаем текст как есть if len(entities) == 0 { return text } type tagSpan struct { open string close string start int end int len int } // Преобразуем текст в руны для правильной работы с UTF-8 runes := []rune(text) // Сначала находим все url entities, чтобы исключить перекрывающиеся форматирования urlRanges := make([][2]int, 0) // Также отмечаем диапазоны, которые нужно полностью исключить из текста (простые url ссылки) skipRanges := make(map[[2]int]bool) for _, entity := range entities { if entity.Type == "url" || entity.Type == "text_link" { urlRanges = append(urlRanges, [2]int{entity.Offset, entity.Offset + entity.Length}) // Для простых url (не text_link) с пригласительными ссылками - пропускаем текст URL // Создаем пустой , а сам URL не сохраняем if entity.Type == "url" { entityText := "" if entity.Offset+entity.Length <= len(runes) { entityText = string(runes[entity.Offset : entity.Offset+entity.Length]) } if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") { skipRanges[[2]int{entity.Offset, entity.Offset + entity.Length}] = true } } } } // Проверяем, находится ли entity внутри URL isInsideURL := func(offset, length int) bool { for _, urlRange := range urlRanges { // Если entity полностью внутри URL (но не сам URL) if offset > urlRange[0] && offset+length <= urlRange[1] { return true } // Если entity пересекается с URL if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) { return true } } return false } var spans []tagSpan for _, entity := range entities { entityText := "" if entity.Offset+entity.Length <= len(runes) { entityText = string(runes[entity.Offset : entity.Offset+entity.Length]) } // Пропускаем форматирование (bold, italic, etc), если оно находится внутри URL if entity.Type != "url" && entity.Type != "text_link" && isInsideURL(entity.Offset, entity.Length) { continue } var openTag, closeTag string switch entity.Type { case "bold": openTag, closeTag = "", "" case "italic": openTag, closeTag = "", "" case "underline": openTag, closeTag = "", "" case "strikethrough": openTag, closeTag = "", "" case "code": openTag, closeTag = "", "" case "pre": openTag, closeTag = "
", "
" case "url": // Конвертируем простые URL в HTML-ссылки // Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") { // Пригласительная ссылка - создаем пустой // Текст ссылки будет пропущен при рендеринге (см. skipRanges) openTag = `` closeTag = "" } else { // Обычная ссылка - оставляем как есть (ссылка на себя) openTag = fmt.Sprintf("", entityText) closeTag = "" } case "text_link": if entity.URL != "" { // Проверяем, является ли URL пригласительной ссылкой if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") { openTag = `` closeTag = "" } else { openTag = fmt.Sprintf("", entity.URL) closeTag = "" } } default: _ = entityText } if openTag != "" && closeTag != "" { spans = append(spans, tagSpan{ open: openTag, close: closeTag, start: entity.Offset, end: entity.Offset + entity.Length, len: entity.Length, }) } } opens := make(map[int][]tagSpan) closes := make(map[int][]tagSpan) for _, span := range spans { opens[span.start] = append(opens[span.start], span) closes[span.end] = append(closes[span.end], span) } // Функция для проверки, находится ли позиция в skipRanges isInSkipRange := func(pos int) bool { for skipRange := range skipRanges { if pos >= skipRange[0] && pos < skipRange[1] { return true } } return false } // Пишем текст один раз, вставляя HTML-теги по позициям // и экранируя HTML-спецсимволы var result strings.Builder for i := 0; i <= len(runes); i++ { if closing, ok := closes[i]; ok { sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len }) for _, span := range closing { result.WriteString(span.close) } } if opening, ok := opens[i]; ok { sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len }) for _, span := range opening { result.WriteString(span.open) } } if i < len(runes) { // Пропускаем символы, которые находятся в skipRanges (текст пригласительных ссылок) if isInSkipRange(i) { continue } // Экранируем HTML-спецсимволы switch runes[i] { case '<': result.WriteString("<") case '>': result.WriteString(">") case '&': result.WriteString("&") default: result.WriteRune(runes[i]) } } } return result.String() }