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
}