package screens import ( "context" "fmt" "regexp" "strconv" "strings" "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" ) type AddCreativeCtx struct { CreativeEditorFields ProjectID string WorkspaceID string BackState bot.State } type AddCreativeStart struct{ ctx *AddCreativeCtx } type AddCreativeEdit struct{ ctx *AddCreativeCtx } type AddCreativeInput struct{ ctx *AddCreativeCtx } const msgWaitCreative = ` + Добавить креатив 📄 Пришлите креатив который хотите добавить • Обрабатываем любое сообщение Telegram • Отправляйте сразу оформленный пост • На следующем шаге можно добавить кнопки или изменить контент ` const msgConfirmCreativeFormat = ` ➕ Добавляем этот креатив? Название креатива: %s Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов. ` const msgDownloadMediaError = ` ❌ Не удалось загрузить медиа Попробуйте удалить медиа и добавить заново. ` const msgInviteLinkRequired = ` ❌ Ошибка валидации Текст должен содержать ОДНУ инвайт-ссылку вашего канала! Формат ссылки: https://t.me/+xxx Пример правильного текста: Присоединяйтесь к нашему каналу! https://t.me/+AbCdEfGhIjKlMn Нажмите "✎ Текст" ниже чтобы исправить. ` const msgInviteLinkTooMany = ` ❌ Слишком много ссылок Текст должен содержать ТОЛЬКО ОДНУ инвайт-ссылку. У вас их несколько. Удалите лишние. Нажмите "✎ Текст" ниже чтобы исправить. ` const msgCreateError = ` ❌ Ошибка создания Не удалось создать креатив. Попробуйте ещё раз или вернитесь назад. ` const msgCreativeCreated = ` ✅ Креатив успешно создан! 📄 Название: %s ` const msgCreativeCreatedButtons = ` 🔘 Кнопок добавлено: %d ` func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) { kb := Keyboard(Row(Button("« Отмена", "cancel"))) b.Render(msgWaitCreative, kb, mode) } func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return } if u.CallbackQuery.Data == "cancel" && s.ctx.BackState != nil { b.SetState(s.ctx.BackState, bot.EditMessage) } } func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message == nil { return } s.ctx.extractCreativeFromMessage(u.Message) if s.ctx.Name == nil { name := s.ctx.generateCreativeName() s.ctx.Name = &name } s.ctx.DeleteUserMessage(b, u.Message.ID) b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.NewMessage) } func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {} func (s *AddCreativeStart) Exit() {} func (s *AddCreativeEdit) Enter(b *bot.Bot, _ bot.RenderMode) { s.ctx.showCreativeConfirmation(b) } func (s *AddCreativeEdit) 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.ctx.BackState != nil { b.SetState(s.ctx.BackState, bot.EditMessage) } case data == "edit_text": s.ctx.InputMode = inputModeText b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) case data == "add_button": s.ctx.InputMode = inputModeButtonText s.ctx.PendingButtonType = "invite" b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) case data == "add_media": s.ctx.InputMode = inputModeMedia b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) case data == "delete_media": s.ctx.MediaType = "" s.ctx.MediaFileID = "" s.ctx.MediaChanged = true s.ctx.UpdateCreativePreview(b) s.Enter(b, bot.EditMessage) case data == "confirm_create": s.ctx.createCreative(b) case strings.HasPrefix(data, "delete_button:"): indexStr := strings.TrimPrefix(data, "delete_button:") index, err := strconv.Atoi(indexStr) if err != nil || index < 0 || index >= len(s.ctx.Buttons) { return } s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...) s.ctx.UpdateCreativePreview(b) s.Enter(b, bot.EditMessage) default: s.Enter(b, bot.NewMessage) } } func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message == nil || u.Message.Text == "" { return } newName := u.Message.Text runes := []rune(newName) if len(runes) > 200 { newName = string(runes[:200]) } s.ctx.Name = &newName s.ctx.DeleteUserMessage(b, u.Message.ID) s.Enter(b, bot.EditMessage) } func (s *AddCreativeEdit) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *AddCreativeEdit) Exit() {} func (s *AddCreativeInput) Enter(b *bot.Bot, _ bot.RenderMode) { switch s.ctx.InputMode { case inputModeText: s.ctx.ShowTextEditPanel(b) case inputModeButtonText: s.ctx.ShowAddButtonPanel(b) case inputModeButtonURL: s.ctx.ShowButtonURLPanel(b) case inputModeMedia: s.ctx.ShowMediaPanel(b) } } func (s *AddCreativeInput) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return } switch u.CallbackQuery.Data { case "button_type_invite": s.ctx.PendingButtonType = "invite" s.ctx.ShowAddButtonPanel(b) case "button_type_custom": s.ctx.PendingButtonType = "custom" s.ctx.ShowAddButtonPanel(b) case "cancel_add_button": s.ctx.CancelPendingButton(b) b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) case "cancel_edit", "cancel_media": s.ctx.ClearInputMode() b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) } } func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) { if u.Message == nil { return } switch s.ctx.InputMode { case inputModeText: if u.Message.Text == "" { return } text := ui.FormatMessageHTML(u.Message) s.ctx.Text = &text s.ctx.DeleteUserMessage(b, u.Message.ID) s.ctx.ClearInputMode() b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) case inputModeButtonText: if u.Message.Text == "" { return } buttonText := u.Message.Text s.ctx.DeleteUserMessage(b, u.Message.ID) if s.ctx.PendingButtonType == "custom" { s.ctx.AddCustomButtonPlaceholder(buttonText) s.ctx.UpdateCreativePreview(b) s.ctx.InputMode = inputModeButtonURL b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage) return } s.ctx.AddInviteButton(buttonText) s.ctx.UpdateCreativePreview(b) s.ctx.PendingButtonType = "" s.ctx.ClearInputMode() b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) case inputModeButtonURL: if u.Message.Text == "" { return } url := strings.TrimSpace(u.Message.Text) s.ctx.DeleteUserMessage(b, u.Message.ID) if !s.ctx.IsValidButtonURL(url, true) { s.ctx.ShowInvalidButtonURLPanel(b) return } s.ctx.UpdateLastButtonURL(url) s.ctx.UpdateCreativePreview(b) s.ctx.PendingButtonType = "" s.ctx.ClearInputMode() b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) case inputModeMedia: if !s.ctx.SetMediaFromMessage(u.Message) { return } s.ctx.DeleteUserMessage(b, u.Message.ID) s.ctx.UpdateCreativePreview(b) s.ctx.ClearInputMode() b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage) } } func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {} func (s *AddCreativeInput) Exit() {} func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) { if message.Text != "" || message.Caption != "" { text := ui.FormatMessageHTML(message) if text != "" { s.Text = &text } } s.SetMediaFromMessage(message) if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 { for _, row := range message.ReplyMarkup.InlineKeyboard { for _, button := range row { if button.URL != "" { s.Buttons = append(s.Buttons, InlineButton{ Text: button.Text, URL: button.URL, }) } } } } } func (s *AddCreativeCtx) generateCreativeName() string { if s.Text != nil && *s.Text != "" { re := regexp.MustCompile(`<[^>]*>`) cleanText := re.ReplaceAllString(*s.Text, "") runes := []rune(strings.TrimSpace(cleanText)) if len(runes) > 10 { return string(runes[:10]) + "..." } if len(runes) > 0 { return string(runes) } } return "Новый креатив" } func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) { if s.CreativeMessageID == nil { s.SendCreativePreview(b) } else { s.UpdateCreativePreview(b) } escapedName := ui.EscapeHTML(*s.Name) confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName) buttons := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel") s.ShowControlPanel(b, confirmText, buttons) } func (s *AddCreativeCtx) createCreative(b *bot.Bot) { var err error var mediaData []byte if s.MediaFileID != "" { mediaData, err = b.DownloadFileBytes(s.MediaFileID) if err != nil { log.Error().Err(err).Msg("Failed to download creative media") buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel") s.ShowControlPanel(b, msgDownloadMediaError, buttons) return } } buttons := make([]backend.CreativeButton, 0, len(s.Buttons)) for _, button := range s.Buttons { buttons = append(buttons, backend.CreativeButton{ Text: button.Text, URL: button.URL, }) } log.Info().Str("text", *s.Text).Msg("Creative text") creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{ Name: *s.Name, Text: *s.Text, MediaType: s.MediaType, MediaFileID: s.MediaFileID, MediaData: mediaData, Buttons: buttons, }) 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 = msgInviteLinkRequired } else if strings.Contains(errMsg, "Creative text must contain only one invite link") { userMsg = msgInviteLinkTooMany } else { userMsg = msgCreateError } buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel") s.ShowControlPanel(b, userMsg, buttons) return } successText := fmt.Sprintf(msgCreativeCreated, creative.Name) if len(s.Buttons) > 0 { successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons)) } b.SendNew(successText, Keyboard()) if s.BackState != nil { b.SetState(s.BackState, bot.NewMessage) } }