Files
tgex-backend/telegram_bot/screens/creative_editor.go
Artem Tsyrulnikov eb54b01835 фиксы
2026-01-08 21:50:04 +03:00

639 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
// Состояние для добавления кнопки
WaitingForButtonText bool
WaitingForButtonURL bool
CurrentButtonText *string
WaitingForMedia bool
PendingButtonType string
// ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
// Предыдущее состояние для определения изменения типа сообщения
previousHasMedia bool
}
const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
// GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string {
var result string
if e.Text != nil && *e.Text != "" {
text := strings.TrimSpace(*e.Text)
// Проверяем что текст не пустой
if text == "" {
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is empty, using default")
return result + `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
// Заменяем пригласительные ссылки с классом tg-link на preview-ссылку
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
replacementCount := 0
for strings.Contains(text, `<a class="tg-link">`) {
startIdx := strings.Index(text, `<a class="tg-link">`)
if startIdx == -1 {
break
}
endIdx := strings.Index(text[startIdx:], "</a>")
if endIdx == -1 {
log.Warn().Msg("Found <a class=\"tg-link\"> but no closing </a>")
break
}
endIdx += startIdx
// Извлекаем текст между тегами
linkText := text[startIdx+len(`<a class="tg-link">`) : endIdx]
log.Info().
Int("replacement_num", replacementCount).
Str("linkText_before_sanitize", linkText).
Msg("Processing tg-link")
// ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности
// Это нужно для старых креативов с неправильным HTML
tagsRemoved := 0
for {
openTag := strings.Index(linkText, "<")
if openTag == -1 {
break
}
closeTag := strings.Index(linkText[openTag:], ">")
if closeTag == -1 {
log.Warn().Str("linkText", linkText).Msg("Found < but no closing >")
break
}
// Удаляем тег
removedTag := linkText[openTag : openTag+closeTag+1]
linkText = linkText[:openTag] + linkText[openTag+closeTag+1:]
tagsRemoved++
log.Info().
Str("removed_tag", removedTag).
Str("linkText_after", linkText).
Int("tags_removed_total", tagsRemoved).
Msg("Removed HTML tag from link text")
}
linkText = strings.TrimSpace(linkText)
// Если текст ссылки не пустой, добавляем атрибут href с примером ссылки
// Если пустой, показываем просто текст примера ссылки (без тега)
var replacement string
if linkText != "" {
// Есть текст - добавляем href атрибут к тегу
replacement = fmt.Sprintf(`<a class="tg-link" href="%s">%s</a>`, inviteLinkPreviewURL, linkText)
} else {
// Пусто - показываем просто текст примера ссылки
replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL)
}
log.Info().
Str("linkText_after_sanitize", linkText).
Int("tags_removed", tagsRemoved).
Str("replacement", replacement).
Msg("Sanitized link text")
text = text[:startIdx] + replacement + text[endIdx+len("</a>"):]
replacementCount++
}
log.Info().
Int("replacements_made", replacementCount).
Str("final_text", text).
Msg("Finished processing tg-links")
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках)
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
return result + text
}
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
return result + `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
// GetCreativeButtons возвращает кнопки для превью креатива
func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton {
var buttons [][]echotron.InlineKeyboardButton
for _, btn := range e.Buttons {
url := btn.URL
if url == inviteLinkPlaceholder {
url = inviteLinkPreviewURL
}
buttons = append(buttons, []echotron.InlineKeyboardButton{
{
Text: btn.Text,
URL: 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,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
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,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
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,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
},
)
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 := `<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
Выберите, что изменить:`
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 {
// Определяем откуда брать текст и 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(entities) == 0 {
return text
}
type tagSpan struct {
open string
close string
start int
end int
len int
}
// Преобразуем текст в руны для правильной работы с UTF-8
runes := []rune(text)
// Сначала находим все url entities, чтобы исключить перекрывающиеся форматирования
urlRanges := make([][2]int, 0)
// Также отмечаем диапазоны, которые нужно полностью исключить из текста (простые url ссылки)
skipRanges := make(map[[2]int]bool)
for _, entity := range entities {
if entity.Type == "url" || entity.Type == "text_link" {
urlRanges = append(urlRanges, [2]int{entity.Offset, entity.Offset + entity.Length})
// Для простых url (не text_link) с пригласительными ссылками - пропускаем текст URL
// Создаем пустой <a class="tg-link"></a>, а сам URL не сохраняем
if entity.Type == "url" {
entityText := ""
if entity.Offset+entity.Length <= len(runes) {
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
}
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
skipRanges[[2]int{entity.Offset, entity.Offset + entity.Length}] = true
}
}
}
}
// Проверяем, находится ли entity внутри URL
isInsideURL := func(offset, length int) bool {
for _, urlRange := range urlRanges {
// Если entity полностью внутри URL (но не сам URL)
if offset > urlRange[0] && offset+length <= urlRange[1] {
return true
}
// Если entity пересекается с URL
if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
return true
}
}
return false
}
var spans []tagSpan
for _, entity := range entities {
entityText := ""
if entity.Offset+entity.Length <= len(runes) {
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
}
// Пропускаем форматирование (bold, italic, etc), если оно находится внутри URL
if entity.Type != "url" && entity.Type != "text_link" && isInsideURL(entity.Offset, entity.Length) {
continue
}
var openTag, closeTag string
switch entity.Type {
case "bold":
openTag, closeTag = "<b>", "</b>"
case "italic":
openTag, closeTag = "<i>", "</i>"
case "underline":
openTag, closeTag = "<u>", "</u>"
case "strikethrough":
openTag, closeTag = "<s>", "</s>"
case "code":
openTag, closeTag = "<code>", "</code>"
case "pre":
openTag, closeTag = "<pre>", "</pre>"
case "url":
// Конвертируем простые URL в HTML-ссылки
// Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
// Пригласительная ссылка - создаем пустой <a class="tg-link"></a>
// Текст ссылки будет пропущен при рендеринге (см. skipRanges)
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
// Обычная ссылка - оставляем как есть (ссылка на себя)
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
closeTag = "</a>"
}
case "text_link":
if entity.URL != "" {
// Проверяем, является ли URL пригласительной ссылкой
if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
closeTag = "</a>"
}
}
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)
}
// Функция для проверки, находится ли позиция в skipRanges
isInSkipRange := func(pos int) bool {
for skipRange := range skipRanges {
if pos >= skipRange[0] && pos < skipRange[1] {
return true
}
}
return false
}
// Пишем текст один раз, вставляя HTML-теги по позициям
// и экранируя 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) {
// Пропускаем символы, которые находятся в skipRanges (текст пригласительных ссылок)
if isInSkipRange(i) {
continue
}
// Экранируем HTML-спецсимволы
switch runes[i] {
case '<':
result.WriteString("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}
return result.String()
}