Files
tgex-backend/tg_bot/screens/creative_editor.go
2026-01-21 12:16:02 +03:00

530 lines
16 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"
"regexp"
"strings"
"github.com/NicoNex/echotron/v3"
"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
}
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
type CreativeEditorFields struct {
// Данные креатива
Name *string
Text *string
Buttons []InlineButton
// Медиа
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
MediaChanged bool
// Режим ввода
InputMode string
// Состояние для добавления кнопки
PendingButtonType string
// ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
// Предыдущее состояние для определения изменения типа сообщения
previousHasMedia bool
previousMediaType string
previousMediaFileID string
}
const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/+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>`)
var htmlTagPattern = regexp.MustCompile(`<[^>]+>`)
const (
inputModeText = "text"
inputModeButtonText = "button_text"
inputModeButtonURL = "button_url"
inputModeMedia = "media"
)
const msgCreativeDefault = `
<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>
`
const msgCreativeManagement = `
<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
Выберите, что изменить:
`
func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
keyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: buttons,
}
if len(buttons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
return keyboard
}
// 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(htmlTagPattern.ReplaceAllString(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
}
// Если есть медиа, отправляем с медиа
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
if id, err := sendText(); 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")
return
}
msgID = id
}
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
e.CreativeMessageID = &msgID
e.previousHasMedia = e.MediaFileID != ""
e.previousMediaType = e.MediaType
e.previousMediaFileID = e.MediaFileID
}
// 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 != ""
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
if currentHasMedia != e.previousHasMedia {
// Удаляем оба сообщения
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 := 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]) + "..."
}
log.Error().
Err(err).
Int("creative_message_id", *e.CreativeMessageID).
Str("media_type", e.MediaType).
Int("text_len", len([]rune(creativeText))).
Str("text_preview", textPreview).
Msg(msg)
}
// Если есть медиа - редактируем caption
updated := false
if e.MediaFileID != "" {
mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType
if mediaChanged {
var media echotron.InputMedia
switch e.MediaType {
case "photo":
media = echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(e.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "video":
media = echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(e.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "animation":
media = echotron.InputMediaAnimation{
Type: echotron.MediaTypeAnimation,
Media: echotron.NewInputFileID(e.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
}
if media != nil {
_, err := b.EditMessageMedia(
msgID,
media,
&echotron.MessageMediaOptions{
ReplyMarkup: keyboard,
},
)
if err != nil {
logEditError("Failed to edit message media", err)
} else {
updated = true
}
} else {
log.Warn().Str("media_type", e.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 {
e.previousHasMedia = currentHasMedia
e.previousMediaType = e.MediaType
e.previousMediaFileID = e.MediaFileID
}
}
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 {
switch {
case message.Photo != nil && len(message.Photo) > 0:
photo := message.Photo[len(message.Photo)-1]
e.MediaType = "photo"
e.MediaFileID = photo.FileID
case message.Video != nil:
e.MediaType = "video"
e.MediaFileID = message.Video.FileID
case message.Animation != nil:
e.MediaType = "animation"
e.MediaFileID = message.Animation.FileID
default:
return false
}
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"),
))
buttons = append(buttons, Row(Button(" Добавить кнопку", "add_button")))
// Кнопка удаления медиа (если медиа добавлено)
if e.MediaFileID != "" {
buttons = append(buttons, Row(
Button("⌫ Убрать медиа", "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)
}
}