package screens import ( "context" "fmt" "strings" "example.com/m/adapter/backend" "example.com/m/bot" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) type AddCreative struct { ProjectID string WorkspaceID string BackState bot.State // Промежуточные данные для многошагового ввода Name *string Text *string Buttons []InlineButton // Медиа MediaType string // "photo", "video", "animation" MediaFileID string // File ID из Telegram // Состояние для добавления кнопки WaitingForButtonText bool WaitingForButtonURL bool CurrentButtonText *string WaitingForMedia bool // ID сообщения с креативом (которое постоянно обновляется) CreativeMessageID *int // ID сообщения с панелью управления ControlPanelMessageID *int } type InlineButton struct { Text string URL string } func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { text := `➕ Добавить креатив Введите название креатива: Например: • Летняя акция • Баннер 1920x1080 • Видео промо` buttons := [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel")), } keyboard := bot.Keyboard(buttons...) 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.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.showControlPanel(b, `➕ Добавить кнопку Введите текст кнопки: Например: • Перейти на сайт • Написать нам`, [][]echotron.InlineKeyboardButton{ 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.updateCreativePreview(b) s.showManagementPanel(b) 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" { s.Buttons = s.Buttons[:len(s.Buttons)-1] s.updateCreativePreview(b) } } s.WaitingForButtonText = false s.WaitingForButtonURL = false s.CurrentButtonText = nil s.showManagementPanel(b) case data == "cancel_edit", data == "cancel_media": // Отменяем редактирование текста или добавление медиа s.WaitingForMedia = false s.showManagementPanel(b) 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) s.showManagementPanel(b) } } } default: s.Enter(b, bot.NewMessage) } } func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message == nil { return } // Шаг 1: Получаем название if s.Name == nil { if u.Message.Text == "" { return } name := u.Message.Text s.Name = &name // Отправляем креатив + панель управления s.sendCreativeAndPanel(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 handled = true } else if u.Message.Video != nil { s.MediaType = "video" s.MediaFileID = u.Message.Video.FileID handled = true } else if u.Message.Animation != nil { s.MediaType = "animation" s.MediaFileID = u.Message.Animation.FileID 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) s.showManagementPanel(b) 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 { buttonText := u.Message.Text s.CurrentButtonText = &buttonText s.WaitingForButtonText = false s.WaitingForButtonURL = true // Сразу создаём кнопку с placeholder URL (используем валидный URL) s.Buttons = append(s.Buttons, InlineButton{ Text: buttonText, URL: "https://example.com", }) // Обновляем превью креатива, чтобы показать новую кнопку s.updateCreativePreview(b) // Редактируем панель управления - просим URL s.showControlPanel(b, `💡 Введите URL для кнопки: URL должен начинаться с https:// Например: https://example.com https://t.me/yourchannel`, [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) return } // Обработка добавления кнопки - URL if s.WaitingForButtonURL { url := u.Message.Text // Базовая валидация URL if !strings.HasPrefix(url, "https://") { s.showControlPanel(b, `❌ Неверный формат URL URL должен начинаться с https:// Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{ bot.Row(bot.Button("« Отмена", "cancel_add_button")), }) 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) return } // Шаг 2: Получаем текст if s.Text == nil { text := s.getFormattedText(u.Message) s.Text = &text // Обновляем креатив и возвращаем панель управления s.updateCreativePreview(b) s.showManagementPanel(b) return } // Если текст уже задан - это повторное редактирование text := s.getFormattedText(u.Message) s.Text = &text // Обновляем креатив и возвращаем панель управления s.updateCreativePreview(b) s.showManagementPanel(b) } func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return } // getFormattedText конвертирует текст сообщения с entities в HTML func (s *AddCreative) getFormattedText(message *echotron.Message) string { if message.Text == "" { return "" } // Если нет entities - возвращаем текст как есть if len(message.Entities) == 0 { return message.Text } // Преобразуем текст в руны для правильной работы с UTF-8 runes := []rune(message.Text) result := "" lastPos := 0 // Обрабатываем entities for _, entity := range message.Entities { // Добавляем текст до entity if entity.Offset > lastPos { result += string(runes[lastPos:entity.Offset]) } // Получаем текст entity entityText := string(runes[entity.Offset : entity.Offset+entity.Length]) // Оборачиваем в соответствующие HTML-теги switch entity.Type { case "bold": result += "" + entityText + "" case "italic": result += "" + entityText + "" case "underline": result += "" + entityText + "" case "strikethrough": result += "" + entityText + "" case "code": result += "" + entityText + "" case "pre": result += "
" + entityText + "
" case "text_link": if entity.URL != "" { result += fmt.Sprintf("%s", entity.URL, entityText) } else { result += entityText } case "url": result += entityText default: result += entityText } lastPos = entity.Offset + entity.Length } // Добавляем оставшийся текст if lastPos < len(runes) { result += string(runes[lastPos:]) } return result } // sendCreativeAndPanel отправляет превью креатива и панель управления func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { // 1. Отправляем превью креатива s.sendCreativePreview(b) // 2. Отправляем панель управления s.showManagementPanel(b) } // sendCreativePreview отправляет новое сообщение с превью креатива (с медиа или без) func (s *AddCreative) sendCreativePreview(b *bot.Bot) { creativeText := s.getCreativeText() creativeButtons := s.getCreativeButtons() // Создаем клавиатуру с пустым массивом (не nil!) keyboard := echotron.InlineKeyboardMarkup{ InlineKeyboard: creativeButtons, } if len(creativeButtons) == 0 { keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} } var msgID int // Если есть медиа, отправляем с медиа if s.MediaFileID != "" { var res echotron.APIResponseMessage var err error switch s.MediaType { case "photo": res, err = b.SendPhoto( echotron.NewInputFileID(s.MediaFileID), b.ChatID, &echotron.PhotoOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) case "video": res, err = b.SendVideo( echotron.NewInputFileID(s.MediaFileID), b.ChatID, &echotron.VideoOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) case "animation": res, err = b.SendAnimation( echotron.NewInputFileID(s.MediaFileID), b.ChatID, &echotron.AnimationOptions{ Caption: creativeText, ParseMode: echotron.HTML, ReplyMarkup: keyboard, }, ) } if err != nil { log.Error().Err(err).Str("media_type", s.MediaType).Msg("Failed to send media") // Fallback to text message b.SendNew(creativeText, keyboard) msgID = b.LastMessageID } else if res.Result != nil { msgID = res.Result.ID } } else { // Отправляем обычное текстовое сообщение b.SendNew(creativeText, keyboard) msgID = b.LastMessageID } // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! s.CreativeMessageID = &msgID log.Info().Int("creative_msg_id", *s.CreativeMessageID).Bool("has_media", s.MediaFileID != "").Msg("Creative preview sent") } // updateCreativePreview обновляет превью креатива func (s *AddCreative) updateCreativePreview(b *bot.Bot) { if s.CreativeMessageID == nil { log.Warn().Msg("updateCreativePreview: CreativeMessageID is nil") return } log.Info(). Int("creative_msg_id", *s.CreativeMessageID). Bool("has_media", s.MediaFileID != ""). Str("media_type", s.MediaType). Msg("Updating creative preview") // Удаляем оба сообщения (креатив и панель) if s.CreativeMessageID != nil { _, err := b.DeleteMessage(b.ChatID, *s.CreativeMessageID) if err != nil { log.Error().Err(err).Int("msg_id", *s.CreativeMessageID).Msg("Failed to delete old creative preview") } } if s.ControlPanelMessageID != nil { _, err := b.DeleteMessage(b.ChatID, *s.ControlPanelMessageID) if err != nil { log.Error().Err(err).Int("msg_id", *s.ControlPanelMessageID).Msg("Failed to delete old control panel") } s.ControlPanelMessageID = nil } // Отправляем оба сообщения заново в правильном порядке s.sendCreativePreview(b) s.showManagementPanel(b) log.Info().Int("new_creative_msg_id", *s.CreativeMessageID).Msg("Creative preview updated") } // getCreativeText возвращает текст для превью креатива func (s *AddCreative) getCreativeText() string { if s.Text != nil { return *s.Text } return ` Я твой креатив У меня пока нет текста, добавь его ниже. ` } // getCreativeButtons возвращает кнопки для превью креатива func (s *AddCreative) getCreativeButtons() [][]echotron.InlineKeyboardButton { var buttons [][]echotron.InlineKeyboardButton for _, btn := range s.Buttons { buttons = append(buttons, []echotron.InlineKeyboardButton{ { Text: btn.Text, URL: btn.URL, }, }) } return buttons } // showManagementPanel показывает панель управления креативом func (s *AddCreative) showManagementPanel(b *bot.Bot) { text := `☝️ Выше отображается превью вашего креатива — так он будет выглядеть при отправке Используйте кнопки ниже для редактирования:` var buttons [][]echotron.InlineKeyboardButton // Кнопки редактирования row1 := []echotron.InlineKeyboardButton{} if s.Text != nil { row1 = append(row1, bot.Button("✏️ Текст", "edit_text")) } else { row1 = append(row1, bot.Button("➕ Текст", "edit_text")) } if s.MediaFileID != "" { row1 = append(row1, bot.Button("✏️ Медиа", "add_media")) } else { row1 = append(row1, bot.Button("➕ Медиа", "add_media")) } row1 = append(row1, bot.Button("➕ Кнопка", "add_button")) buttons = append(buttons, row1) // Кнопка удаления медиа (если медиа добавлено) if s.MediaFileID != "" { buttons = append(buttons, bot.Row( bot.Button("🗑 Удалить медиа", "delete_media"), )) } // Кнопки удаления (если есть кнопки) for i, btn := range s.Buttons { buttons = append(buttons, bot.Row( bot.Button(fmt.Sprintf("🗑 %s", btn.Text), fmt.Sprintf("delete_button:%d", i)), )) } // Финальные кнопки if s.Text != nil { buttons = append(buttons, bot.Row( bot.Button("✅ Подтвердить", "confirm_create"), bot.Button("« Отмена", "cancel"), )) } else { buttons = append(buttons, bot.Row( bot.Button("« Отмена", "cancel"), )) } s.showControlPanel(b, text, buttons) } // showControlPanel редактирует панель управления с произвольным текстом и кнопками func (s *AddCreative) showControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) { if s.ControlPanelMessageID == nil { // Отправляем новую панель keyboard := bot.Keyboard(buttons...) b.SendNew(text, keyboard) // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! controlPanelMsgID := b.LastMessageID s.ControlPanelMessageID = &controlPanelMsgID log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Control panel sent") } else { // Редактируем существующую панель log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Editing control panel") oldLastMessageID := b.LastMessageID b.LastMessageID = *s.ControlPanelMessageID keyboard := bot.Keyboard(buttons...) b.Edit(text, keyboard) b.LastMessageID = oldLastMessageID log.Info().Msg("Control panel updated") } } func (s *AddCreative) createCreative(b *bot.Bot) { // Получаем JWT jwt, err := s.ensureJWT(b) if err != nil { log.Error().Err(err).Msg("Failed to get JWT") b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start login", bot.Keyboard()) return } // Создаём креатив через API creative, err := b.Backend.CreateCreative( context.Background(), jwt, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{ Name: *s.Name, Text: *s.Text, }, ) 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 = `❌ Ошибка создания Не удалось создать креатив. Попробуйте ещё раз или вернитесь назад.` } b.SendNew(userMsg, bot.Keyboard( bot.Row(bot.Button("« Назад", "back_from_preview")), )) 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) } } func (s *AddCreative) ensureJWT(b *bot.Bot) (string, error) { // Проверяем, есть ли JWT в сессии if b.Session.JWT != "" { return b.Session.JWT, nil } // Если нет, запрашиваем новый jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) if err != nil { return "", err } // Сохраняем в сессию b.Session.JWT = jwt return jwt, nil }