Files
tgex-backend/tg_bot/screens/creative_editor.go
Artem Tsyrulnikov 62cd191846 fix
2026-02-28 13:43:24 +03:00

778 lines
23 KiB
Go
Raw Permalink 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"
"regexp"
"strings"
"sync"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
// InlineButton представляет кнопку с URL
type InlineButton struct {
Text string
URL string
}
type MediaItem struct {
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
}
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
type CreativeEditorFields struct {
// Данные креатива
Name *string
Text *string
Buttons []InlineButton
Tag *string // "testing" or "production"
// Медиа
MediaItems []MediaItem
MediaChanged bool
// Режим ввода
InputMode string
// Состояние для добавления кнопки
PendingButtonType string
// ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
// ID сообщений дополнительного медиа (после первого)
ExtraMediaMessageIDs []int
pendingMediaGroupMu sync.Mutex
pendingMediaGroupID string
pendingMediaGroupTimer *time.Timer
pendingMediaGroupGen int64
// Предыдущее состояние для определения изменения типа сообщения
previousMediaItems []MediaItem
}
const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/joinchat/nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
const buttonURLPlaceholder = "https://example.com"
var inviteLinkPattern = regexp.MustCompile(`https?://t\.me/(?:\+|joinchat/)[a-zA-Z0-9_-]+`)
var inviteLinkTagPattern = regexp.MustCompile(`<a\s+class="tg-link">\s*(.*?)\s*</a>`)
const (
inputModeText = "text"
inputModeButtonText = "button_text"
inputModeButtonURL = "button_url"
inputModeMedia = "media"
)
const msgCreativeDefault = `
<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>
`
const msgCreativeManagement = `
<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
Выберите, что изменить:
`
const msgCreativePreviewBroken = "⚠️ Не удалось показать превью: креатив содержит некорректную разметку. Создайте его заново или отредактируйте текст."
func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
keyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: buttons,
}
if len(buttons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
return keyboard
}
func copyMediaItems(items []MediaItem) []MediaItem {
if len(items) == 0 {
return nil
}
clone := make([]MediaItem, len(items))
copy(clone, items)
return clone
}
func mediaItemsEqual(a, b []MediaItem) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].MediaType != b[i].MediaType || a[i].MediaFileID != b[i].MediaFileID {
return false
}
}
return true
}
func (e *CreativeEditorFields) primaryMedia() *MediaItem {
if len(e.MediaItems) == 0 {
return nil
}
return &e.MediaItems[0]
}
func (e *CreativeEditorFields) hasMultipleMedia() bool {
return len(e.MediaItems) > 1
}
func (e *CreativeEditorFields) clearExtraMediaMessages(b *bot.Bot) {
for _, msgID := range e.ExtraMediaMessageIDs {
b.DeleteMessage(b.ChatID, msgID)
}
e.ExtraMediaMessageIDs = nil
}
func (e *CreativeEditorFields) CleanupMessages(b *bot.Bot) {
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
if e.ControlPanelMessageID != nil {
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
e.ControlPanelMessageID = nil
}
b.LastMessageID = 0
}
const mediaGroupDebounce = 2 * time.Second
func (e *CreativeEditorFields) scheduleMediaGroupAction(groupID string, action func()) {
e.pendingMediaGroupMu.Lock()
defer e.pendingMediaGroupMu.Unlock()
if groupID == "" {
action()
return
}
if e.pendingMediaGroupTimer != nil {
e.pendingMediaGroupTimer.Stop()
}
e.pendingMediaGroupID = groupID
e.pendingMediaGroupGen++
gen := e.pendingMediaGroupGen
e.pendingMediaGroupTimer = time.AfterFunc(mediaGroupDebounce, func() {
e.pendingMediaGroupMu.Lock()
currentGroup := e.pendingMediaGroupID
currentGen := e.pendingMediaGroupGen
if currentGen == gen && currentGroup == groupID {
e.pendingMediaGroupID = ""
e.pendingMediaGroupTimer = nil
}
e.pendingMediaGroupMu.Unlock()
if currentGen == gen && currentGroup == groupID {
action()
}
})
}
func isTelegramParseError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "can't parse entities") || strings.Contains(msg, "Unexpected end tag")
}
// GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string {
if e.Text == nil {
return msgCreativeDefault
}
text := strings.TrimSpace(ui.SanitizeHTML(*e.Text))
if text == "" {
return msgCreativeDefault
}
// Заменяем плейсхолдеры tg-link на кликабельную preview-ссылку.
text = inviteLinkTagPattern.ReplaceAllStringFunc(text, func(match string) string {
sub := inviteLinkTagPattern.FindStringSubmatch(match)
inner := ""
if len(sub) > 1 {
inner = strings.TrimSpace(sub[1])
}
if inner == "" {
inner = inviteLinkPreviewURL
}
return fmt.Sprintf(`<a href="%s">%s</a>`, inviteLinkPreviewURL, inner)
})
// Заменяем пригласительные ссылки на preview URL только для отображения.
text = inviteLinkPattern.ReplaceAllString(text, inviteLinkPreviewURL)
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках).
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
return text
}
// 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 := buildCreativeKeyboard(creativeButtons)
var msgID int
sendText := func() (int, error) {
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
if err != nil {
return 0, err
}
if res.Result == nil {
return 0, fmt.Errorf("send message: empty result")
}
return res.Result.ID, nil
}
sendFallback := func() (int, error) {
res, err := b.SendMessage(msgCreativePreviewBroken, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
if err != nil {
return 0, err
}
if res.Result == nil {
return 0, fmt.Errorf("send message: empty result")
}
return res.Result.ID, nil
}
// Если есть медиа, отправляем с медиа
if len(e.MediaItems) > 1 {
e.clearExtraMediaMessages(b)
group := make([]echotron.GroupableInputMedia, 0, len(e.MediaItems))
for i, item := range e.MediaItems {
caption := ""
parseMode := echotron.ParseMode("")
if i == 0 {
caption = creativeText
parseMode = echotron.HTML
}
switch item.MediaType {
case "photo":
group = append(group, echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
case "video":
group = append(group, echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
case "animation":
group = append(group, echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(item.MediaFileID),
Caption: caption,
ParseMode: parseMode,
})
default:
log.Warn().Str("media_type", item.MediaType).Msg("Unsupported media type for media group")
}
}
if len(group) > 0 {
res, err := b.SendMediaGroup(b.ChatID, group, nil)
if err != nil {
log.Error().Err(err).Msg("Failed to send media group")
if id, err := sendText(); err == nil {
msgID = id
} else if isTelegramParseError(err) {
if id, err := sendFallback(); err == nil {
msgID = id
}
}
} else if len(res.Result) > 0 {
msgID = res.Result[0].ID
for i := 1; i < len(res.Result); i++ {
e.ExtraMediaMessageIDs = append(e.ExtraMediaMessageIDs, res.Result[i].ID)
}
}
}
} else if media := e.primaryMedia(); media != nil {
var res echotron.APIResponseMessage
var err error
switch media.MediaType {
case "photo":
res, err = b.SendPhoto(
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.PhotoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "video":
res, err = b.SendVideo(
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.VideoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "animation":
res, err = b.SendAnimation(
echotron.NewInputFileID(media.MediaFileID),
b.ChatID,
&echotron.AnimationOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
}
if err != nil {
log.Error().Err(err).Str("media_type", media.MediaType).Msg("Failed to send media")
// Fallback to text message - используем прямой SendMessage
if id, err := sendText(); err == nil {
msgID = id
} else if isTelegramParseError(err) {
if id, err := sendFallback(); err == nil {
msgID = id
}
}
} else if res.Result != nil {
msgID = res.Result.ID
}
} else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не затронуть LastMessageID и не затронуть cleanup
id, err := sendText()
if err != nil {
log.Error().Err(err).Msg("Failed to send creative preview")
if isTelegramParseError(err) {
id, err = sendFallback()
if err == nil {
msgID = id
} else {
log.Error().Err(err).Msg("Failed to send fallback creative preview")
return
}
} else {
return
}
} else {
msgID = id
}
}
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
e.CreativeMessageID = &msgID
e.previousMediaItems = copyMediaItems(e.MediaItems)
// Устанавливаем LastMessageID чтобы ShowControlPanel мог корректно работать
// ВАЖНО: Это позволяет избежать конфликтов при навигации между экранами
b.LastMessageID = msgID
}
// UpdateCreativePreview обновляет превью креатива
func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
if e.CreativeMessageID == nil {
log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil")
// Если превью еще не создано, создаем его
e.SendCreativePreview(b)
return
}
currentHasMedia := len(e.MediaItems) > 0
previousHasMedia := len(e.previousMediaItems) > 0
mediaItemsChanged := !mediaItemsEqual(e.MediaItems, e.previousMediaItems)
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
if currentHasMedia != previousHasMedia {
// Удаляем оба сообщения
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
if e.ControlPanelMessageID != nil {
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
e.ControlPanelMessageID = nil
}
// Создаем превью заново (панель будет создана через ShowManagementPanel после этого)
e.SendCreativePreview(b)
return
}
if mediaItemsChanged && (len(e.MediaItems) > 1 || len(e.previousMediaItems) > 1) {
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
e.clearExtraMediaMessages(b)
e.SendCreativePreview(b)
return
}
// Тип не изменился - просто редактируем на месте
creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons()
keyboard := buildCreativeKeyboard(creativeButtons)
msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID)
logEditError := func(msg string, err error) {
textPreview := creativeText
runes := []rune(textPreview)
if len(runes) > 200 {
textPreview = string(runes[:200]) + "..."
}
mediaType := ""
if media := e.primaryMedia(); media != nil {
mediaType = media.MediaType
}
log.Error().
Err(err).
Int("creative_message_id", *e.CreativeMessageID).
Str("media_type", mediaType).
Int("text_len", len([]rune(creativeText))).
Str("text_preview", textPreview).
Msg(msg)
}
// Если есть медиа - редактируем caption
updated := false
if media := e.primaryMedia(); media != nil {
var prevMedia MediaItem
if len(e.previousMediaItems) > 0 {
prevMedia = e.previousMediaItems[0]
}
mediaChanged := media.MediaFileID != prevMedia.MediaFileID || media.MediaType != prevMedia.MediaType
if mediaChanged {
var inputMedia echotron.InputMedia
switch media.MediaType {
case "photo":
inputMedia = echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "video":
inputMedia = echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "animation":
inputMedia = echotron.InputMediaAnimation{
Type: echotron.MediaTypeAnimation,
Media: echotron.NewInputFileID(media.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
}
if inputMedia != nil {
_, err := b.EditMessageMedia(
msgID,
inputMedia,
&echotron.MessageMediaOptions{
ReplyMarkup: keyboard,
},
)
if err != nil {
logEditError("Failed to edit message media", err)
} else {
updated = true
}
} else {
log.Warn().Str("media_type", media.MediaType).Msg("Unsupported media type for edit")
}
} else {
_, err := b.EditMessageCaption(
msgID,
&echotron.MessageCaptionOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
if err != nil {
logEditError("Failed to edit media caption", err)
} else {
updated = true
}
}
} else {
// Текстовое сообщение - редактируем текст
_, err := b.EditMessageText(
creativeText,
msgID,
&echotron.MessageTextOptions{
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
},
)
if err != nil {
logEditError("Failed to edit message text", err)
} else {
updated = true
}
}
if updated {
if currentHasMedia {
e.previousMediaItems = copyMediaItems(e.MediaItems)
} else {
e.previousMediaItems = nil
}
}
}
func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) {
_, err := b.DeleteMessage(b.ChatID, messageID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
}
func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool {
var item MediaItem
switch {
case message.Photo != nil && len(message.Photo) > 0:
photo := message.Photo[len(message.Photo)-1]
item.MediaType = "photo"
item.MediaFileID = photo.FileID
case message.Video != nil:
item.MediaType = "video"
item.MediaFileID = message.Video.FileID
case message.Animation != nil:
item.MediaType = "animation"
item.MediaFileID = message.Animation.FileID
default:
return false
}
e.MediaItems = append(e.MediaItems, item)
e.MediaChanged = true
return true
}
func (e *CreativeEditorFields) AddInviteButton(text string) {
e.Buttons = append(e.Buttons, InlineButton{
Text: text,
URL: inviteLinkPlaceholder,
})
}
func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) {
e.Buttons = append(e.Buttons, InlineButton{
Text: text,
URL: buttonURLPlaceholder,
})
}
func (e *CreativeEditorFields) UpdateLastButtonURL(url string) {
if len(e.Buttons) > 0 {
e.Buttons[len(e.Buttons)-1].URL = url
}
}
func (e *CreativeEditorFields) ClearInputMode() {
e.InputMode = ""
e.PendingButtonType = ""
}
func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) {
if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 {
lastButton := e.Buttons[len(e.Buttons)-1]
if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder {
e.Buttons = e.Buttons[:len(e.Buttons)-1]
e.UpdateCreativePreview(b)
}
}
e.ClearInputMode()
}
func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool {
if allowHTTP {
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
}
return strings.HasPrefix(url, "https://")
}
// ShowManagementPanel показывает панель управления креативом
// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить")
// confirmCallback - callback для кнопки подтверждения
// cancelCallback - callback для кнопки отмены
// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
text := msgCreativeManagement
buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...)
e.ShowControlPanel(b, text, buttons)
}
func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
var buttons [][]echotron.InlineKeyboardButton
// Блок редактирования контента
buttons = append(buttons, Row(
Button("✎ Текст", "edit_text"),
Button(" Медиа", "add_media"),
))
addButtonLabel := " Добавить кнопку"
addButtonCallback := "add_button"
if e.hasMultipleMedia() {
addButtonLabel = "Кнопки недоступны для >1 медиа"
addButtonCallback = "empty"
}
buttons = append(buttons, Row(Button(addButtonLabel, addButtonCallback)))
// Кнопка удаления медиа (если медиа добавлено)
if len(e.MediaItems) > 0 {
label := "⌫ Убрать медиа"
if len(e.MediaItems) > 1 {
label = fmt.Sprintf("⌫ Убрать медиа (%d)", len(e.MediaItems))
}
buttons = append(buttons, Row(Button(label, "delete_media")))
}
// Кнопки удаления (если есть кнопки)
if len(e.Buttons) > 0 {
var row []echotron.InlineKeyboardButton
for i, btn := range e.Buttons {
row = append(row, 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)
}
// Финальные кнопки
cancelLabel := "← " + cancelText
if e.Text != nil {
buttons = append(buttons, Row(
Button(cancelLabel, cancelCallback),
Button(confirmText, confirmCallback),
))
} else {
buttons = append(buttons, Row(
Button(cancelLabel, cancelCallback),
))
}
return buttons
}
// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
if e.ControlPanelMessageID == nil {
// Отправляем новую панель
keyboard := Keyboard(buttons...)
b.SendNew(text, keyboard)
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
controlPanelMsgID := b.LastMessageID
e.ControlPanelMessageID = &controlPanelMsgID
} else {
// Редактируем существующую панель
// Временно заменяем LastMessageID на ID панели управления
oldLastMessageID := b.LastMessageID
b.LastMessageID = *e.ControlPanelMessageID
defer func() { b.LastMessageID = oldLastMessageID }()
keyboard := Keyboard(buttons...)
b.Edit(text, keyboard)
}
}
func (e *CreativeEditorFields) BuildMediaInputs(b *bot.Bot) ([]backend.CreativeMediaInput, error) {
if len(e.MediaItems) == 0 {
return nil, nil
}
mediaItems := make([]backend.CreativeMediaInput, 0, len(e.MediaItems))
for _, item := range e.MediaItems {
if item.MediaFileID == "" {
continue
}
mediaData, err := b.DownloadFileBytes(item.MediaFileID)
if err != nil {
return nil, err
}
mediaItems = append(mediaItems, backend.CreativeMediaInput{
MediaType: item.MediaType,
MediaFileID: item.MediaFileID,
MediaData: mediaData,
})
}
return mediaItems, nil
}