package screens import ( "context" "fmt" "regexp" "strings" "example.com/m/adapter/backend" "example.com/m/bot" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) type AddCreative struct { CreativeEditorFields // Встраивание общих полей ProjectID string WorkspaceID string BackState bot.State WaitingForCreative bool // Ждем пересылки сообщения с креативом CreativeMessageSent bool // Креатив уже получен и показан WaitingForTextEdit bool // Ждем редактирования текста креатива } func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { text := `… › Креативы › Добавление + Добавить креатив 📄 Пришлите креатив который хотите добавить • Обрабатываем любое сообщение Telegram • Отправляйте сразу оформленный пост • На следующем шаге можно добавить кнопки или изменить контент` buttons := [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel")), } keyboard := bot.Keyboard(buttons...) s.WaitingForCreative = true b.Render(text, keyboard, mode) } func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return } data := u.CallbackQuery.Data switch { case data == "cancel": if s.BackState != nil { b.SetState(s.BackState, bot.EditMessage) } case data == "edit_text": // Начинаем редактирование текста s.WaitingForTextEdit = true s.ShowControlPanel(b, `✎ Введите текст креатива:Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала Формат ссылки: https://t.me/+xxx Пример: Присоединяйтесь к нашему каналу! https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel_edit")), }) case data == "add_button": // Начинаем процесс добавления кнопки s.WaitingForButtonText = true s.PendingButtonType = "invite" s.ShowControlPanel(b, `+ Добавить кнопку Введите текст кнопки: Например: • Перейти на сайт • Написать нам Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ bot.Row( bot.Button("✓ Ссылка на канал", "button_type_invite"), bot.Button("Своя ссылка", "button_type_custom"), ), bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) case data == "add_media": // Начинаем процесс добавления медиа s.WaitingForMedia = true s.ShowControlPanel(b, `+ Добавить медиа Отправьте фото, видео или GIF Это медиа будет отображаться в креативе вместе с текстом`, [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel_media")), }) case data == "delete_media": // Удаляем медиа s.MediaType = "" s.MediaFileID = "" s.MediaChanged = true s.UpdateCreativePreview(b) if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } case data == "cancel_add_button": // Отменяем добавление кнопки // Если мы уже создали кнопку с placeholder - удаляем её if s.WaitingForButtonURL && len(s.Buttons) > 0 { lastButton := s.Buttons[len(s.Buttons)-1] if lastButton.URL == "https://example.com" || lastButton.URL == inviteLinkPlaceholder { s.Buttons = s.Buttons[:len(s.Buttons)-1] s.UpdateCreativePreview(b) } } s.WaitingForButtonText = false s.WaitingForButtonURL = false s.PendingButtonType = "" s.CurrentButtonText = nil if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } case data == "button_type_invite": s.PendingButtonType = "invite" s.ShowControlPanel(b, `+ Добавить кнопку Введите текст кнопки: Например: • Перейти на сайт • Написать нам Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ bot.Row( bot.Button("✓ Ссылка на канал", "button_type_invite"), bot.Button("Своя ссылка", "button_type_custom"), ), bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) case data == "button_type_custom": s.PendingButtonType = "custom" s.ShowControlPanel(b, `+ Добавить кнопку Введите текст кнопки: Например: • Перейти на сайт • Написать нам Ссылка на канал подставится автоматически при создании закупа.`, [][]echotron.InlineKeyboardButton{ bot.Row( bot.Button("Ссылка на канал", "button_type_invite"), bot.Button("✓ Своя ссылка", "button_type_custom"), ), bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) 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 if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } case data == "confirm_create": // Создаём креатив s.createCreative(b) case strings.HasPrefix(data, "delete_button:"): // Удаляем кнопку по индексу parts := strings.Split(data, ":") if len(parts) == 2 { var index int if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil { if index >= 0 && index < len(s.Buttons) { s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) s.UpdateCreativePreview(b) if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } } } } default: s.Enter(b, bot.NewMessage) } } func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message == nil { return } // Шаг 1: Ждем пересылки креатива (любое сообщение) if s.WaitingForCreative { // Извлекаем данные из сообщения s.extractCreativeFromMessage(u.Message) // Генерируем название из текста или дефолтное 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 s.MediaChanged = true handled = true } else if u.Message.Video != nil { s.MediaType = "video" s.MediaFileID = u.Message.Video.FileID s.MediaChanged = true handled = true } else if u.Message.Animation != nil { s.MediaType = "animation" s.MediaFileID = u.Message.Animation.FileID s.MediaChanged = true 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.WaitingForMedia = false s.UpdateCreativePreview(b) if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } return } } // Обработка добавления кнопки - текст кнопки if s.WaitingForButtonText { if u.Message.Text == "" { return } buttonText := u.Message.Text s.CurrentButtonText = &buttonText s.WaitingForButtonText = false // Удаляем сообщение пользователя _, err := b.DeleteMessage(b.ChatID, u.Message.ID) if err != nil { log.Error().Err(err).Msg("Failed to delete user message") } if s.PendingButtonType == "custom" { s.WaitingForButtonURL = true s.Buttons = append(s.Buttons, InlineButton{ Text: buttonText, URL: "https://example.com", }) s.UpdateCreativePreview(b) s.ShowControlPanel(b, `✧ Введите URL для кнопки: URL должен начинаться с https:// Например: https://example.com https://t.me/yourchannel`, [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) } else { s.Buttons = append(s.Buttons, InlineButton{ Text: buttonText, URL: inviteLinkPlaceholder, }) s.UpdateCreativePreview(b) s.PendingButtonType = "" if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } } return } // Обработка добавления кнопки - URL if s.WaitingForButtonURL { if u.Message.Text == "" { return } url := u.Message.Text // Удаляем сообщение пользователя _, 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 URL должен начинаться с https:// Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) return } if len(s.Buttons) > 0 { s.Buttons[len(s.Buttons)-1].URL = url } s.WaitingForButtonURL = false s.CurrentButtonText = nil s.PendingButtonType = "" s.UpdateCreativePreview(b) if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } return } // Если ждем редактирования текста креатива 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) if s.CreativeMessageSent { s.showCreativeConfirmation(b) } else { s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } return } // Если креатив уже показан и пользователь вводит текст - это изменение названия if s.CreativeMessageSent && u.Message.Text != "" { newName := u.Message.Text // Ограничение 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 теги с помощью regex re := regexp.MustCompile(`<[^>]*>`) cleanText := re.ReplaceAllString(*s.Text, "") // Берем первые 50 символов runes := []rune(strings.TrimSpace(cleanText)) if len(runes) > 50 { return string(runes[:50]) + "..." } if len(runes) > 0 { return string(runes) } } // Дефолтное название return "Новый креатив" } // escapeHTML экранирует HTML-символы для безопасного вывода func escapeHTML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") return s } // buildEditorButtons создает кнопки для панели редактирования креатива func (s *AddCreative) buildEditorButtons(confirmText, cancelCallback string) [][]echotron.InlineKeyboardButton { 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("← "+cancelCallback, "cancel"), bot.Button(confirmText, "confirm_create"), )) } else { buttons = append(buttons, bot.Row( bot.Button("← "+cancelCallback, "cancel"), )) } return buttons } // showCreativeConfirmation показывает превью креатива + панель с предложением добавить func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) { // 1. Отправляем/обновляем превью креатива if s.CreativeMessageID == nil { s.SendCreativePreview(b) } else { s.UpdateCreativePreview(b) } // 2. Формируем текст панели управления // Экранируем HTML-символы в названии для безопасного вывода escapedName := escapeHTML(*s.Name) confirmText := fmt.Sprintf(`➕ Добавляем этот креатив? Название креатива: %s Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.`, escapedName) // 3. Показываем панель с кнопками редактирования + сохранения buttons := s.buildEditorButtons("✓ Сохранить", "Отмена") s.ShowControlPanel(b, confirmText, buttons) } // sendCreativeAndPanel отправляет превью креатива и панель управления func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { // 1. Отправляем превью креатива s.SendCreativePreview(b) // 2. Отправляем панель управления s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } func (s *AddCreative) createCreative(b *bot.Bot) { // JWT уже создан в Bot.Update(), просто берем из сессии jwt := b.Session.JWT if jwt == "" { log.Error().Msg("JWT is empty in session") b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start login", bot.Keyboard()) return } var err error var mediaData []byte if s.MediaFileID != "" { mediaData, err = b.DownloadFileBytes(s.MediaFileID) if err != nil { log.Error().Err(err).Msg("Failed to download creative media") // Показываем ошибку через панель управления buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена") s.ShowControlPanel(b, "❌ Не удалось загрузить медиа\n\nПопробуйте удалить медиа и добавить заново.", buttons) return } } buttons := make([]backend.CreativeButton, 0, len(s.Buttons)) for _, button := range s.Buttons { buttons = append(buttons, backend.CreativeButton{ Text: button.Text, URL: button.URL, }) } // Логируем что отправляем 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(), jwt, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{ Name: *s.Name, Text: *s.Text, MediaType: s.MediaType, MediaFileID: s.MediaFileID, MediaData: mediaData, Buttons: buttons, }, ) if err != nil { log.Error().Err(err).Msg("Failed to create creative") // Проверяем, это ошибка валидации инвайт-ссылки errMsg := err.Error() var userMsg string if strings.Contains(errMsg, "Creative text must contain one invite link") { userMsg = `❌ Ошибка валидации Текст должен содержать ОДНУ инвайт-ссылку вашего канала! Формат ссылки: https://t.me/+xxx Пример правильного текста: Присоединяйтесь к нашему каналу! https://t.me/+AbCdEfGhIjKlMn Нажмите "✎ Текст" ниже чтобы исправить.` } else if strings.Contains(errMsg, "Creative text must contain only one invite link") { userMsg = `❌ Слишком много ссылок Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку. У вас их несколько. Удалите лишние. Нажмите "✎ Текст" ниже чтобы исправить.` } else { userMsg = `❌ Ошибка создания Не удалось создать креатив. Попробуйте ещё раз или вернитесь назад.` } // Показываем ошибку через панель управления, чтобы не ломать порядок сообщений buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена") s.ShowControlPanel(b, userMsg, buttons) return } // Успех! Показываем сообщение и возвращаемся successText := `✅ Креатив успешно создан! 📄 Название: ` + creative.Name if len(s.Buttons) > 0 { successText += fmt.Sprintf(` 🔘 Кнопок добавлено: %d`, len(s.Buttons)) } b.SendNew(successText, bot.Keyboard()) // Возвращаемся на экран креативов if s.BackState != nil { b.SetState(s.BackState, bot.NewMessage) } }