Files
tgex-backend/telegram_bot/screens/add_creative.go
Artem Tsyrulnikov 2fb8c83041 new telegram bot
2025-12-30 19:50:15 +03:00

687 lines
20 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 (
"context"
"fmt"
"strings"
"example.com/m/adapter/backend"
"example.com/m/bot"
"github.com/NicoNex/echotron/v3"
"github.com/rs/zerolog/log"
)
type AddCreative struct {
ProjectID string
WorkspaceID string
BackState bot.State
// Промежуточные данные для многошагового ввода
Name *string
Text *string
Buttons []InlineButton
// Медиа
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
// Состояние для добавления кнопки
WaitingForButtonText bool
WaitingForButtonURL bool
CurrentButtonText *string
WaitingForMedia bool
// ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
}
type InlineButton struct {
Text string
URL string
}
func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) {
text := `<b> Добавить креатив</b>
Введите <b>название</b> креатива:
<i>Например:</i>
• Летняя акция
• Баннер 1920x1080
• Видео промо`
buttons := [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel")),
}
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "cancel":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case data == "edit_text":
// Редактируем панель управления - просим текст
s.showControlPanel(b, `<b>✏️ Введите текст креатива:</b>
⚠️ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel_edit")),
})
case data == "add_button":
// Начинаем процесс добавления кнопки
s.WaitingForButtonText = true
s.showControlPanel(b, `<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам`, [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
})
case data == "add_media":
// Начинаем процесс добавления медиа
s.WaitingForMedia = true
s.showControlPanel(b, `<b>📷 Добавить медиа</b>
Отправьте <b>фото, видео или GIF</b>
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel_media")),
})
case data == "delete_media":
// Удаляем медиа
s.MediaType = ""
s.MediaFileID = ""
s.updateCreativePreview(b)
s.showManagementPanel(b)
case data == "cancel_add_button":
// Отменяем добавление кнопки
// Если мы уже создали кнопку с placeholder - удаляем её
if s.WaitingForButtonURL && len(s.Buttons) > 0 {
lastButton := s.Buttons[len(s.Buttons)-1]
if lastButton.URL == "https://example.com" {
s.Buttons = s.Buttons[:len(s.Buttons)-1]
s.updateCreativePreview(b)
}
}
s.WaitingForButtonText = false
s.WaitingForButtonURL = false
s.CurrentButtonText = nil
s.showManagementPanel(b)
case data == "cancel_edit", data == "cancel_media":
// Отменяем редактирование текста или добавление медиа
s.WaitingForMedia = false
s.showManagementPanel(b)
case data == "confirm_create":
// Создаём креатив
s.createCreative(b)
case strings.HasPrefix(data, "delete_button:"):
// Удаляем кнопку по индексу
parts := strings.Split(data, ":")
if len(parts) == 2 {
var index int
if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil {
if index >= 0 && index < len(s.Buttons) {
s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...)
s.updateCreativePreview(b)
s.showManagementPanel(b)
}
}
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
// Шаг 1: Получаем название
if s.Name == nil {
if u.Message.Text == "" {
return
}
name := u.Message.Text
s.Name = &name
// Отправляем креатив + панель управления
s.sendCreativeAndPanel(b)
return
}
// Обработка медиа
if s.WaitingForMedia {
handled := false
if u.Message.Photo != nil && len(u.Message.Photo) > 0 {
// Берем самое большое фото
photo := u.Message.Photo[len(u.Message.Photo)-1]
s.MediaType = "photo"
s.MediaFileID = photo.FileID
handled = true
} else if u.Message.Video != nil {
s.MediaType = "video"
s.MediaFileID = u.Message.Video.FileID
handled = true
} else if u.Message.Animation != nil {
s.MediaType = "animation"
s.MediaFileID = u.Message.Animation.FileID
handled = true
}
if handled {
// Удаляем сообщение пользователя
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
s.WaitingForMedia = false
s.updateCreativePreview(b)
s.showManagementPanel(b)
return
}
}
// Если не медиа и не текст - игнорируем
if u.Message.Text == "" {
return
}
// Удаляем сообщение пользователя
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
// Обработка добавления кнопки - текст кнопки
if s.WaitingForButtonText {
buttonText := u.Message.Text
s.CurrentButtonText = &buttonText
s.WaitingForButtonText = false
s.WaitingForButtonURL = true
// Сразу создаём кнопку с placeholder URL (используем валидный URL)
s.Buttons = append(s.Buttons, InlineButton{
Text: buttonText,
URL: "https://example.com",
})
// Обновляем превью креатива, чтобы показать новую кнопку
s.updateCreativePreview(b)
// Редактируем панель управления - просим URL
s.showControlPanel(b, `<b>💡 Введите URL для кнопки:</b>
URL должен начинаться с <code>https://</code>
<i>Например:</i>
<code>https://example.com</code>
<code>https://t.me/yourchannel</code>`, [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
})
return
}
// Обработка добавления кнопки - URL
if s.WaitingForButtonURL {
url := u.Message.Text
// Базовая валидация URL
if !strings.HasPrefix(url, "https://") {
s.showControlPanel(b, `<b>❌ Неверный формат URL</b>
URL должен начинаться с <code>https://</code>
Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
})
return
}
// Обновляем URL последней кнопки (которую создали с placeholder)
if len(s.Buttons) > 0 {
s.Buttons[len(s.Buttons)-1].URL = url
}
s.WaitingForButtonURL = false
s.CurrentButtonText = nil
// Обновляем креатив и возвращаем панель управления
s.updateCreativePreview(b)
s.showManagementPanel(b)
return
}
// Шаг 2: Получаем текст
if s.Text == nil {
text := s.getFormattedText(u.Message)
s.Text = &text
// Обновляем креатив и возвращаем панель управления
s.updateCreativePreview(b)
s.showManagementPanel(b)
return
}
// Если текст уже задан - это повторное редактирование
text := s.getFormattedText(u.Message)
s.Text = &text
// Обновляем креатив и возвращаем панель управления
s.updateCreativePreview(b)
s.showManagementPanel(b)
}
func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return }
// getFormattedText конвертирует текст сообщения с entities в HTML
func (s *AddCreative) getFormattedText(message *echotron.Message) string {
if message.Text == "" {
return ""
}
// Если нет entities - возвращаем текст как есть
if len(message.Entities) == 0 {
return message.Text
}
// Преобразуем текст в руны для правильной работы с UTF-8
runes := []rune(message.Text)
result := ""
lastPos := 0
// Обрабатываем entities
for _, entity := range message.Entities {
// Добавляем текст до entity
if entity.Offset > lastPos {
result += string(runes[lastPos:entity.Offset])
}
// Получаем текст entity
entityText := string(runes[entity.Offset : entity.Offset+entity.Length])
// Оборачиваем в соответствующие HTML-теги
switch entity.Type {
case "bold":
result += "<b>" + entityText + "</b>"
case "italic":
result += "<i>" + entityText + "</i>"
case "underline":
result += "<u>" + entityText + "</u>"
case "strikethrough":
result += "<s>" + entityText + "</s>"
case "code":
result += "<code>" + entityText + "</code>"
case "pre":
result += "<pre>" + entityText + "</pre>"
case "text_link":
if entity.URL != "" {
result += fmt.Sprintf("<a href=\"%s\">%s</a>", entity.URL, entityText)
} else {
result += entityText
}
case "url":
result += entityText
default:
result += entityText
}
lastPos = entity.Offset + entity.Length
}
// Добавляем оставшийся текст
if lastPos < len(runes) {
result += string(runes[lastPos:])
}
return result
}
// sendCreativeAndPanel отправляет превью креатива и панель управления
func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) {
// 1. Отправляем превью креатива
s.sendCreativePreview(b)
// 2. Отправляем панель управления
s.showManagementPanel(b)
}
// sendCreativePreview отправляет новое сообщение с превью креатива (с медиа или без)
func (s *AddCreative) sendCreativePreview(b *bot.Bot) {
creativeText := s.getCreativeText()
creativeButtons := s.getCreativeButtons()
// Создаем клавиатуру с пустым массивом (не nil!)
keyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: creativeButtons,
}
if len(creativeButtons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
var msgID int
// Если есть медиа, отправляем с медиа
if s.MediaFileID != "" {
var res echotron.APIResponseMessage
var err error
switch s.MediaType {
case "photo":
res, err = b.SendPhoto(
echotron.NewInputFileID(s.MediaFileID),
b.ChatID,
&echotron.PhotoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "video":
res, err = b.SendVideo(
echotron.NewInputFileID(s.MediaFileID),
b.ChatID,
&echotron.VideoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "animation":
res, err = b.SendAnimation(
echotron.NewInputFileID(s.MediaFileID),
b.ChatID,
&echotron.AnimationOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
}
if err != nil {
log.Error().Err(err).Str("media_type", s.MediaType).Msg("Failed to send media")
// Fallback to text message
b.SendNew(creativeText, keyboard)
msgID = b.LastMessageID
} else if res.Result != nil {
msgID = res.Result.ID
}
} else {
// Отправляем обычное текстовое сообщение
b.SendNew(creativeText, keyboard)
msgID = b.LastMessageID
}
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
s.CreativeMessageID = &msgID
log.Info().Int("creative_msg_id", *s.CreativeMessageID).Bool("has_media", s.MediaFileID != "").Msg("Creative preview sent")
}
// updateCreativePreview обновляет превью креатива
func (s *AddCreative) updateCreativePreview(b *bot.Bot) {
if s.CreativeMessageID == nil {
log.Warn().Msg("updateCreativePreview: CreativeMessageID is nil")
return
}
log.Info().
Int("creative_msg_id", *s.CreativeMessageID).
Bool("has_media", s.MediaFileID != "").
Str("media_type", s.MediaType).
Msg("Updating creative preview")
// Удаляем оба сообщения (креатив и панель)
if s.CreativeMessageID != nil {
_, err := b.DeleteMessage(b.ChatID, *s.CreativeMessageID)
if err != nil {
log.Error().Err(err).Int("msg_id", *s.CreativeMessageID).Msg("Failed to delete old creative preview")
}
}
if s.ControlPanelMessageID != nil {
_, err := b.DeleteMessage(b.ChatID, *s.ControlPanelMessageID)
if err != nil {
log.Error().Err(err).Int("msg_id", *s.ControlPanelMessageID).Msg("Failed to delete old control panel")
}
s.ControlPanelMessageID = nil
}
// Отправляем оба сообщения заново в правильном порядке
s.sendCreativePreview(b)
s.showManagementPanel(b)
log.Info().Int("new_creative_msg_id", *s.CreativeMessageID).Msg("Creative preview updated")
}
// getCreativeText возвращает текст для превью креатива
func (s *AddCreative) getCreativeText() string {
if s.Text != nil {
return *s.Text
}
return `<i> Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
// getCreativeButtons возвращает кнопки для превью креатива
func (s *AddCreative) getCreativeButtons() [][]echotron.InlineKeyboardButton {
var buttons [][]echotron.InlineKeyboardButton
for _, btn := range s.Buttons {
buttons = append(buttons, []echotron.InlineKeyboardButton{
{
Text: btn.Text,
URL: btn.URL,
},
})
}
return buttons
}
// showManagementPanel показывает панель управления креативом
func (s *AddCreative) showManagementPanel(b *bot.Bot) {
text := `<b>☝️ Выше отображается <b>превью вашего креатива</b> — так он будет выглядеть при отправке</b>
Используйте кнопки ниже для редактирования:`
var buttons [][]echotron.InlineKeyboardButton
// Кнопки редактирования
row1 := []echotron.InlineKeyboardButton{}
if s.Text != nil {
row1 = append(row1, bot.Button("✏️ Текст", "edit_text"))
} else {
row1 = append(row1, bot.Button(" Текст", "edit_text"))
}
if s.MediaFileID != "" {
row1 = append(row1, bot.Button("✏️ Медиа", "add_media"))
} else {
row1 = append(row1, bot.Button(" Медиа", "add_media"))
}
row1 = append(row1, bot.Button(" Кнопка", "add_button"))
buttons = append(buttons, row1)
// Кнопка удаления медиа (если медиа добавлено)
if s.MediaFileID != "" {
buttons = append(buttons, bot.Row(
bot.Button("🗑 Удалить медиа", "delete_media"),
))
}
// Кнопки удаления (если есть кнопки)
for i, btn := range s.Buttons {
buttons = append(buttons, bot.Row(
bot.Button(fmt.Sprintf("🗑 %s", btn.Text), fmt.Sprintf("delete_button:%d", i)),
))
}
// Финальные кнопки
if s.Text != nil {
buttons = append(buttons, bot.Row(
bot.Button("✅ Подтвердить", "confirm_create"),
bot.Button("« Отмена", "cancel"),
))
} else {
buttons = append(buttons, bot.Row(
bot.Button("« Отмена", "cancel"),
))
}
s.showControlPanel(b, text, buttons)
}
// showControlPanel редактирует панель управления с произвольным текстом и кнопками
func (s *AddCreative) showControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
if s.ControlPanelMessageID == nil {
// Отправляем новую панель
keyboard := bot.Keyboard(buttons...)
b.SendNew(text, keyboard)
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
controlPanelMsgID := b.LastMessageID
s.ControlPanelMessageID = &controlPanelMsgID
log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Control panel sent")
} else {
// Редактируем существующую панель
log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Editing control panel")
oldLastMessageID := b.LastMessageID
b.LastMessageID = *s.ControlPanelMessageID
keyboard := bot.Keyboard(buttons...)
b.Edit(text, keyboard)
b.LastMessageID = oldLastMessageID
log.Info().Msg("Control panel updated")
}
}
func (s *AddCreative) createCreative(b *bot.Bot) {
// Получаем JWT
jwt, err := s.ensureJWT(b)
if err != nil {
log.Error().Err(err).Msg("Failed to get JWT")
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start login", bot.Keyboard())
return
}
// Создаём креатив через API
creative, err := b.Backend.CreateCreative(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
backend.CreateCreativeInput{
Name: *s.Name,
Text: *s.Text,
},
)
if err != nil {
log.Error().Err(err).Msg("Failed to create creative")
// Проверяем, это ошибка валидации инвайт-ссылки
errMsg := err.Error()
var userMsg string
if strings.Contains(errMsg, "Creative text must contain one invite link") {
userMsg = `<b>❌ Ошибка валидации</b>
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример правильного текста:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>`
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
userMsg = `<b>❌ Слишком много ссылок</b>
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
У вас их несколько. Удалите лишние.`
} else {
userMsg = `<b>❌ Ошибка создания</b>
Не удалось создать креатив.
Попробуйте ещё раз или вернитесь назад.`
}
b.SendNew(userMsg, bot.Keyboard(
bot.Row(bot.Button("« Назад", "back_from_preview")),
))
return
}
// Успех! Показываем сообщение и возвращаемся
successText := `<b>✅ Креатив успешно создан!</b>
📄 <b>Название:</b> ` + creative.Name
if len(s.Buttons) > 0 {
successText += fmt.Sprintf(`
🔘 <b>Кнопок добавлено:</b> %d`, len(s.Buttons))
}
b.SendNew(successText, bot.Keyboard())
// Возвращаемся на экран креативов
if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
}
func (s *AddCreative) ensureJWT(b *bot.Bot) (string, error) {
// Проверяем, есть ли JWT в сессии
if b.Session.JWT != "" {
return b.Session.JWT, nil
}
// Если нет, запрашиваем новый
jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID)
if err != nil {
return "", err
}
// Сохраняем в сессию
b.Session.JWT = jwt
return jwt, nil
}