From 61f7ed093c0ac75c2465638978900ab660fac320 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Tue, 6 Jan 2026 17:47:31 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D1=80=D0=B5=D0=B0=D1=82=D0=B8=D0=B2=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=81=D1=82=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml.example | 6 - src/domain/creative.py | 16 +- telegram_bot/main.go | 3 +- telegram_bot/screens/add_creative.go | 344 +++++++++++++++++++---- telegram_bot/screens/creative_details.go | 47 +++- telegram_bot/screens/creative_editor.go | 43 ++- 6 files changed, 366 insertions(+), 93 deletions(-) diff --git a/docker-compose.yml.example b/docker-compose.yml.example index 4dd32c9..d7065f3 100644 --- a/docker-compose.yml.example +++ b/docker-compose.yml.example @@ -20,12 +20,6 @@ services: context: . ports: - "8000:8000" - environment: - - DB__HOST=postgres - - DB__PORT=5432 - - DB__USER=user - - DB__PASSWORD=password - - DB__DATABASE=tgex depends_on: postgres: condition: service_healthy diff --git a/src/domain/creative.py b/src/domain/creative.py index 0567b1b..4160847 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -47,20 +47,32 @@ MAX_CREATIVE_MEDIA_BYTES = 20 * 1024 * 1024 _INVITE_LINK_HTML = re.compile(r'([^<]*)', re.IGNORECASE) _INVITE_LINK_PLAIN = re.compile(r'https?://t\.me/\+[a-zA-Z0-9_-]+', re.IGNORECASE) +_INVITE_LINK_REPLACED = re.compile(r'([^<]*)', re.IGNORECASE) def replace_invite_link_with_tag(text: str) -> str: """Replace Telegram invite link (t.me/+xxx) with tracking tag.""" + # Проверяем, есть ли уже замененная ссылка + replaced_count = len(_INVITE_LINK_REPLACED.findall(text)) + html_count = len(_INVITE_LINK_HTML.findall(text)) - plain_count = len(_INVITE_LINK_PLAIN.findall(text)) - total = html_count + plain_count + + # Сначала удаляем HTML-ссылки из текста, чтобы не считать их дважды + text_without_html_links = _INVITE_LINK_HTML.sub('', text) + plain_count = len(_INVITE_LINK_PLAIN.findall(text_without_html_links)) + + total = replaced_count + html_count + plain_count if total == 0: raise CreativeInviteLinkNotFound() if total > 1: raise CreativeMultipleInviteLinks() + # Если ссылка уже заменена, возвращаем текст как есть + if replaced_count == 1: + return text + if html_count == 1: return _INVITE_LINK_HTML.sub(r'\1', text) return _INVITE_LINK_PLAIN.sub('', text) diff --git a/telegram_bot/main.go b/telegram_bot/main.go index f67dedf..5634317 100644 --- a/telegram_bot/main.go +++ b/telegram_bot/main.go @@ -13,8 +13,7 @@ import ( ) const ( - botToken = "8134938541:AAFcZhIGfYynQ9axyuA35R1nd7sPQZodrrQ" - botUsername = "tgex_bot" // TODO: заменить на реальный username бота + botToken = "7718883894:AAHUqtjqHtSs2Rp6Tb4PokqU6syfKmcFvOo" ) func main() { diff --git a/telegram_bot/screens/add_creative.go b/telegram_bot/screens/add_creative.go index feadc0f..0a822d4 100644 --- a/telegram_bot/screens/add_creative.go +++ b/telegram_bot/screens/add_creative.go @@ -14,9 +14,12 @@ import ( type AddCreative struct { CreativeEditorFields // Встраивание общих полей - ProjectID string - WorkspaceID string - BackState bot.State + ProjectID string + WorkspaceID string + BackState bot.State + WaitingForCreative bool // Ждем пересылки сообщения с креативом + CreativeMessageSent bool // Креатив уже получен и показан + WaitingForTextEdit bool // Ждем редактирования текста креатива } func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { @@ -24,18 +27,18 @@ func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { + Добавить креатив -Введите название креатива: +📄 Пришлите креатив который хотите добавить -Например: - ‣ Летняя акция - ‣ Баннер 1920x1080 - ‣ Видео промо` +• Обрабатываем любое сообщение Telegram +• Отправляйте сразу оформленный пост +• На следующем шаге можно добавить кнопки или изменить контент` buttons := [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel")), } keyboard := bot.Keyboard(buttons...) + s.WaitingForCreative = true b.Render(text, keyboard, mode) } @@ -53,7 +56,8 @@ func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) { } case data == "edit_text": - // Редактируем панель управления - просим текст + // Начинаем редактирование текста + s.WaitingForTextEdit = true s.ShowControlPanel(b, `✎ Введите текст креатива:Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала @@ -99,7 +103,12 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ s.MediaFileID = "" s.MediaChanged = true s.UpdateCreativePreview(b) - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } case data == "cancel_add_button": // Отменяем добавление кнопки @@ -114,12 +123,30 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ s.WaitingForButtonText = false s.WaitingForButtonURL = false s.CurrentButtonText = nil - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") - case data == "cancel_edit", data == "cancel_media": - // Отменяем редактирование текста или добавление медиа + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } + + case data == "cancel_edit": + // Отменяем редактирование текста + s.WaitingForTextEdit = false + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } + + case data == "cancel_media": + // Отменяем добавление медиа s.WaitingForMedia = false - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } case data == "confirm_create": // Создаём креатив @@ -134,7 +161,12 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ if index >= 0 && index < len(s.Buttons) { s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) s.UpdateCreativePreview(b) - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } } } } @@ -149,25 +181,36 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { return } - // Шаг 1: Получаем название - if s.Name == nil { - if u.Message.Text == "" { - return - } - name := u.Message.Text - s.Name = &name + // Шаг 1: Ждем пересылки креатива (любое сообщение) + if s.WaitingForCreative { + // Извлекаем данные из сообщения + s.extractCreativeFromMessage(u.Message) - // Отправляем креатив + панель управления - s.sendCreativeAndPanel(b) + // Генерируем название из текста или дефолтное + if s.Name == nil { + name := s.generateCreativeName() + s.Name = &name + } + + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + + s.WaitingForCreative = false + s.CreativeMessageSent = true + + // Показываем превью + панель с предложением добавить + s.showCreativeConfirmation(b) return } - // Обработка медиа + // Обработка медиа (когда явно просим добавить медиа) if s.WaitingForMedia { handled := false if u.Message.Photo != nil && len(u.Message.Photo) > 0 { - // Берем самое большое фото photo := u.Message.Photo[len(u.Message.Photo)-1] s.MediaType = "photo" s.MediaFileID = photo.FileID @@ -186,7 +229,6 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { } if handled { - // Удаляем сообщение пользователя _, err := b.DeleteMessage(b.ChatID, u.Message.ID) if err != nil { log.Error().Err(err).Msg("Failed to delete user message") @@ -194,39 +236,40 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { s.WaitingForMedia = false s.UpdateCreativePreview(b) - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } return } } - // Если не медиа и не текст - игнорируем - if u.Message.Text == "" { - return - } - - // Удаляем сообщение пользователя - _, err := b.DeleteMessage(b.ChatID, u.Message.ID) - if err != nil { - log.Error().Err(err).Msg("Failed to delete user message") - } - // Обработка добавления кнопки - текст кнопки if s.WaitingForButtonText { + if u.Message.Text == "" { + return + } + buttonText := u.Message.Text s.CurrentButtonText = &buttonText s.WaitingForButtonText = false s.WaitingForButtonURL = true - // Сразу создаём кнопку с placeholder URL (используем валидный URL) + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + s.Buttons = append(s.Buttons, InlineButton{ Text: buttonText, URL: "https://example.com", }) - // Обновляем превью креатива, чтобы показать новую кнопку s.UpdateCreativePreview(b) - // Редактируем панель управления - просим URL s.ShowControlPanel(b, `✧ Введите URL для кнопки: URL должен начинаться с https:// @@ -241,9 +284,18 @@ URL должен начинаться с https:// // Обработка добавления кнопки - URL if s.WaitingForButtonURL { + if u.Message.Text == "" { + return + } + url := u.Message.Text - // Базовая валидация URL + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + if !strings.HasPrefix(url, "https://") { s.ShowControlPanel(b, `❌ Неверный формат URL @@ -255,45 +307,212 @@ URL должен начинаться с https:// return } - // Обновляем URL последней кнопки (которую создали с placeholder) if len(s.Buttons) > 0 { s.Buttons[len(s.Buttons)-1].URL = url } s.WaitingForButtonURL = false s.CurrentButtonText = nil - // Обновляем креатив и возвращаем панель управления s.UpdateCreativePreview(b) - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } return } - // Шаг 2: Получаем текст - if s.Text == nil { + // Если ждем редактирования текста креатива + if s.WaitingForTextEdit && u.Message.Text != "" { text := s.GetFormattedText(u.Message) s.Text = &text - // Обновляем креатив и возвращаем панель управления + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + + s.WaitingForTextEdit = false s.UpdateCreativePreview(b) - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + + if s.CreativeMessageSent { + s.showCreativeConfirmation(b) + } else { + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + } return } - // Если текст уже задан - это повторное редактирование - text := s.GetFormattedText(u.Message) - s.Text = &text + // Если креатив уже показан и пользователь вводит текст - это изменение названия + if s.CreativeMessageSent && u.Message.Text != "" { + newName := u.Message.Text - // Обновляем креатив и возвращаем панель управления - s.UpdateCreativePreview(b) - s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") + // Ограничение 200 символов + runes := []rune(newName) + if len(runes) > 200 { + newName = string(runes[:200]) + } + + s.Name = &newName + + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + + // Обновляем панель с новым названием + s.showCreativeConfirmation(b) + return + } } func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return } +// extractCreativeFromMessage извлекает данные креатива из сообщения +func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) { + // Извлекаем текст с форматированием (GetFormattedText теперь поддерживает и text и caption) + if message.Text != "" || message.Caption != "" { + text := s.GetFormattedText(message) + if text != "" { + s.Text = &text + } + } + + // Извлекаем медиа + if message.Photo != nil && len(message.Photo) > 0 { + photo := message.Photo[len(message.Photo)-1] + s.MediaType = "photo" + s.MediaFileID = photo.FileID + s.MediaChanged = true + } else if message.Video != nil { + s.MediaType = "video" + s.MediaFileID = message.Video.FileID + s.MediaChanged = true + } else if message.Animation != nil { + s.MediaType = "animation" + s.MediaFileID = message.Animation.FileID + s.MediaChanged = true + } + + // Извлекаем кнопки из ReplyMarkup + if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 { + for _, row := range message.ReplyMarkup.InlineKeyboard { + for _, button := range row { + // Берем только кнопки с URL + if button.URL != "" { + s.Buttons = append(s.Buttons, InlineButton{ + Text: button.Text, + URL: button.URL, + }) + } + } + } + } +} + +// generateCreativeName генерирует название из текста или дефолтное +func (s *AddCreative) generateCreativeName() string { + // Если есть текст, берем первые слова + if s.Text != nil && *s.Text != "" { + // Удаляем HTML теги для генерации названия + cleanText := strings.ReplaceAll(*s.Text, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + cleanText = strings.ReplaceAll(cleanText, "", "") + + // Берем первые 50 символов + runes := []rune(strings.TrimSpace(cleanText)) + if len(runes) > 50 { + return string(runes[:50]) + "..." + } + if len(runes) > 0 { + return string(runes) + } + } + + // Дефолтное название + return "Новый креатив" +} + +// showCreativeConfirmation показывает превью креатива + панель с предложением добавить +func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) { + + // 1. Отправляем/обновляем превью креатива + if s.CreativeMessageID == nil { + s.SendCreativePreview(b) + } else { + s.UpdateCreativePreview(b) + } + + // 2. Формируем текст панели управления + confirmText := fmt.Sprintf(`➕ Добавляем этот креатив? + +Название креатива: %s + +Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.`, *s.Name) + + // 3. Показываем панель с кнопками редактирования + сохранения + var buttons [][]echotron.InlineKeyboardButton + + // Кнопки редактирования + buttons = append(buttons, bot.Row( + bot.Button("✎ Текст", "edit_text"), + bot.Button("+ Медиа", "add_media"), + )) + buttons = append(buttons, bot.Row(bot.Button("+ Добавить кнопку", "add_button"))) + + // Кнопка удаления медиа (если медиа добавлено) + if s.MediaFileID != "" { + buttons = append(buttons, bot.Row( + bot.Button("⌫ Убрать медиа", "delete_media"), + )) + } + + // Кнопки удаления добавленных кнопок + if len(s.Buttons) > 0 { + var row []echotron.InlineKeyboardButton + for i, btn := range s.Buttons { + row = append(row, bot.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) + } + } + + // Финальные кнопки + if s.Text != nil { + buttons = append(buttons, bot.Row( + bot.Button("← Отмена", "cancel"), + bot.Button("✓ Сохранить", "confirm_create"), + )) + } else { + buttons = append(buttons, bot.Row( + bot.Button("← Отмена", "cancel"), + )) + } + + s.ShowControlPanel(b, confirmText, buttons) +} + // sendCreativeAndPanel отправляет превью креатива и панель управления func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { - // Устанавливаем навигацию - s.Navigation = "… › Креативы › Добавление" // 1. Отправляем превью креатива s.SendCreativePreview(b) @@ -331,6 +550,15 @@ func (s *AddCreative) createCreative(b *bot.Bot) { }) } + // Логируем что отправляем + log.Info(). + Str("name", *s.Name). + Str("text", *s.Text). + Int("buttons_count", len(buttons)). + Interface("buttons", buttons). + Str("media_type", s.MediaType). + Msg("Creating creative with data") + // Создаём креатив через API creative, err := b.Backend.CreateCreative( context.Background(), diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go index c111873..d536fb4 100644 --- a/telegram_bot/screens/creative_details.go +++ b/telegram_bot/screens/creative_details.go @@ -58,9 +58,6 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { } s.MediaChanged = false - // Устанавливаем навигацию - s.Navigation = "… › Креативы › Детали" - log.Info(). Str("creative_id", s.CreativeID). Str("name", creative.Name). @@ -195,6 +192,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { // Обрабатываем добавление медиа if s.WaitingForMedia { + handled := false if u.Message.Photo != nil && len(u.Message.Photo) > 0 { photos := u.Message.Photo largestPhoto := photos[len(photos)-1] @@ -202,26 +200,29 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { s.MediaFileID = largestPhoto.FileID s.MediaChanged = true s.WaitingForMedia = false - - s.UpdateCreativePreview(b) - s.showManagementPanelWithDelete(b) - return + handled = true } else if u.Message.Video != nil { s.MediaType = "video" s.MediaFileID = u.Message.Video.FileID s.MediaChanged = true s.WaitingForMedia = false - - s.UpdateCreativePreview(b) - s.showManagementPanelWithDelete(b) - return + handled = true } else if u.Message.Animation != nil { s.MediaType = "animation" s.MediaFileID = u.Message.Animation.FileID s.MediaChanged = true s.WaitingForMedia = false + handled = true + } + + if handled { + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) @@ -236,6 +237,12 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { s.WaitingForButtonText = false s.WaitingForButtonURL = true + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + // Создаём кнопку с placeholder URL s.Buttons = append(s.Buttons, InlineButton{ Text: buttonText, @@ -257,6 +264,12 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { if s.WaitingForButtonURL && u.Message.Text != "" { url := strings.TrimSpace(u.Message.Text) + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + // Простая валидация URL if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { s.ShowControlPanel(b, `❌ Неверный формат URL @@ -287,6 +300,12 @@ URL должен начинаться с http:// или https text := s.GetFormattedText(u.Message) s.Text = &text + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) return @@ -297,6 +316,12 @@ URL должен начинаться с http:// или https text := s.GetFormattedText(u.Message) s.Text = &text + // Удаляем сообщение пользователя + _, err := b.DeleteMessage(b.ChatID, u.Message.ID) + if err != nil { + log.Error().Err(err).Msg("Failed to delete user message") + } + s.UpdateCreativePreview(b) s.showManagementPanelWithDelete(b) } diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go index dd3e3f8..da3a0e9 100644 --- a/telegram_bot/screens/creative_editor.go +++ b/telegram_bot/screens/creative_editor.go @@ -28,9 +28,6 @@ type CreativeEditorFields struct { MediaFileID string // File ID из Telegram MediaChanged bool - // Навигация (хлебные крошки) - Navigation string - // Состояние для добавления кнопки WaitingForButtonText bool WaitingForButtonURL bool @@ -50,11 +47,6 @@ type CreativeEditorFields struct { func (e *CreativeEditorFields) GetCreativeText() string { var result string - // Добавляем навигацию если она есть - if e.Navigation != "" { - result = fmt.Sprintf("%s\n\n", e.Navigation) - } - if e.Text != nil && *e.Text != "" { // Проверяем что текст не является "визуально пустым" HTML // Например: "" - пустая ссылка @@ -364,13 +356,25 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons // GetFormattedText извлекает форматированный текст из сообщения с entities func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) string { - if message.Text == "" { + // Определяем откуда брать текст и 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(message.Entities) == 0 { - return message.Text + if len(entities) == 0 { + return text } type tagSpan struct { @@ -382,10 +386,10 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin } // Преобразуем текст в руны для правильной работы с UTF-8 - runes := []rune(message.Text) + runes := []rune(text) var spans []tagSpan - for _, entity := range message.Entities { + for _, entity := range entities { // URL уже присутствует в сыром тексте, чтобы не дублировать - пропускаем entity. if entity.Type == "url" { continue @@ -438,6 +442,7 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin } // Пишем текст один раз, вставляя HTML-теги по позициям + // и экранируя HTML-спецсимволы var result strings.Builder for i := 0; i <= len(runes); i++ { if closing, ok := closes[i]; ok { @@ -453,7 +458,17 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin } } if i < len(runes) { - result.WriteRune(runes[i]) + // Экранируем HTML-спецсимволы + switch runes[i] { + case '<': + result.WriteString("<") + case '>': + result.WriteString(">") + case '&': + result.WriteString("&") + default: + result.WriteRune(runes[i]) + } } }