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
// Навигация (хлебные крошки)
Navigation string
// Состояние для добавления кнопки
WaitingForButtonText bool
WaitingForButtonURL bool
CurrentButtonText *string
WaitingForMedia bool
// ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
// Предыдущее состояние для определения изменения типа сообщения
previousHasMedia bool
}
// GetCreativeText возвращает текст для превью креатива
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
// Например: "" - пустая ссылка
text := strings.TrimSpace(*e.Text)
// Убираем пустые HTML-теги (простая проверка)
if text == "" || text == "" {
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is visually empty, using default")
return result + `💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
`
}
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
return result + *e.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 {
buttons = append(buttons, []echotron.InlineKeyboardButton{
{
Text: btn.Text,
URL: btn.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,
})
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,
})
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,
},
)
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, bot.Row(
bot.Button("✎ Текст", "edit_text"),
bot.Button("⧉ Медиа", "add_media"),
))
// Добавление кнопки сразу под основным блоком
buttons = append(buttons, bot.Row(bot.Button("+ Добавить кнопку", "add_button")))
// Кнопка удаления медиа (если медиа добавлено)
if e.MediaFileID != "" {
buttons = append(buttons, bot.Row(
bot.Button("⌫ Убрать медиа", "delete_media"),
))
}
// Кнопки удаления (если есть кнопки)
if len(e.Buttons) > 0 {
var row []echotron.InlineKeyboardButton
for i, btn := range e.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)
}
}
// Дополнительные кнопки (например "Удалить креатив")
for _, extraRow := range extraButtons {
buttons = append(buttons, extraRow)
}
// Финальные кнопки
if e.Text != nil {
buttons = append(buttons, bot.Row(
bot.Button("← Назад", cancelCallback),
bot.Button(confirmButtonText, confirmCallback),
))
} else {
buttons = append(buttons, bot.Row(
bot.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 := bot.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 := bot.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 {
if message.Text == "" {
return ""
}
// Если нет entities - возвращаем текст как есть
if len(message.Entities) == 0 {
return message.Text
}
type tagSpan struct {
open string
close string
start int
end int
len int
}
// Преобразуем текст в руны для правильной работы с UTF-8
runes := []rune(message.Text)
var spans []tagSpan
for _, entity := range message.Entities {
// URL уже присутствует в сыром тексте, чтобы не дублировать - пропускаем entity.
if entity.Type == "url" {
continue
}
entityText := ""
if entity.Offset+entity.Length <= len(runes) {
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
}
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 "text_link": if entity.URL != "" { 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) } // Пишем текст один раз, вставляя 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) { result.WriteRune(runes[i]) } } return result.String() }