refactor
This commit is contained in:
556
tg_bot/screens/creative_editor.go
Normal file
556
tg_bot/screens/creative_editor.go
Normal file
@@ -0,0 +1,556 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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"
|
||||
|
||||
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-ссылку
|
||||
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
|
||||
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]
|
||||
// ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности
|
||||
// Это нужно для старых креативов с неправильным HTML
|
||||
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
|
||||
}
|
||||
// Удаляем тег
|
||||
linkText = linkText[:openTag] + linkText[openTag+closeTag+1:]
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
text = text[:startIdx] + replacement + text[endIdx+len("</a>"):]
|
||||
}
|
||||
|
||||
// Также заменяем плейсхолдер {{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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user