refactor
This commit is contained in:
@@ -43,3 +43,5 @@ func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
|
||||
func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) {
|
||||
b.SetState(&MainMenu{}, bot.EditMessage)
|
||||
}
|
||||
|
||||
func (s *AcceptWorkspaceInvite) Exit() {}
|
||||
|
||||
@@ -4,25 +4,30 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"example.com/m/adapter/backend"
|
||||
"example.com/m/bot"
|
||||
"example.com/m/ui"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type AddCreative struct {
|
||||
CreativeEditorFields // Встраивание общих полей
|
||||
type AddCreativeCtx struct {
|
||||
CreativeEditorFields
|
||||
|
||||
ProjectID string
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
WaitingForCreative bool // Ждем пересылки сообщения с креативом
|
||||
CreativeMessageSent bool // Креатив уже получен и показан
|
||||
WaitingForTextEdit bool // Ждем редактирования текста креатива
|
||||
ProjectID string
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
}
|
||||
|
||||
type AddCreativeStart struct{ ctx *AddCreativeCtx }
|
||||
|
||||
type AddCreativeEdit struct{ ctx *AddCreativeCtx }
|
||||
|
||||
type AddCreativeInput struct{ ctx *AddCreativeCtx }
|
||||
|
||||
const msgWaitCreative = `
|
||||
<b>+ Добавить креатив</b>
|
||||
|
||||
@@ -31,15 +36,102 @@ const msgWaitCreative = `
|
||||
• Обрабатываем любое сообщение Telegram
|
||||
• Отправляйте сразу оформленный пост
|
||||
• На следующем шаге можно добавить кнопки или изменить контент
|
||||
|
||||
`
|
||||
const msgConfirmCreativeFormat = `
|
||||
<b>➕ Добавляем этот креатив?</b>
|
||||
|
||||
<b>Название креатива:</b> %s
|
||||
|
||||
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>
|
||||
|
||||
`
|
||||
const msgDownloadMediaError = `
|
||||
<b>❌ Не удалось загрузить медиа</b>
|
||||
|
||||
Попробуйте удалить медиа и добавить заново.
|
||||
|
||||
`
|
||||
const msgInviteLinkRequired = `
|
||||
<b>❌ Ошибка валидации</b>
|
||||
|
||||
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
<b>Пример правильного текста:</b>
|
||||
<i>Присоединяйтесь к нашему каналу!
|
||||
https://t.me/+AbCdEfGhIjKlMn</i>
|
||||
|
||||
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
|
||||
|
||||
`
|
||||
const msgInviteLinkTooMany = `
|
||||
<b>❌ Слишком много ссылок</b>
|
||||
|
||||
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
|
||||
У вас их несколько. Удалите лишние.
|
||||
|
||||
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
|
||||
|
||||
`
|
||||
const msgCreateError = `
|
||||
<b>❌ Ошибка создания</b>
|
||||
|
||||
Не удалось создать креатив.
|
||||
Попробуйте ещё раз или вернитесь назад.
|
||||
|
||||
`
|
||||
const msgCreativeCreated = `
|
||||
<b>✅ Креатив успешно создан!</b>
|
||||
|
||||
📄 <b>Название:</b> %s
|
||||
|
||||
`
|
||||
const msgCreativeCreatedButtons = `
|
||||
🔘 <b>Кнопок добавлено:</b> %d
|
||||
|
||||
`
|
||||
|
||||
func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
kb := Keyboard(Row(Button("« Отмена", "cancel")))
|
||||
s.WaitingForCreative = true
|
||||
b.Render(msgWaitCreative, kb, mode)
|
||||
}
|
||||
|
||||
func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
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
|
||||
}
|
||||
@@ -48,413 +140,175 @@ func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
|
||||
switch {
|
||||
case data == "cancel":
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.EditMessage)
|
||||
if s.ctx.BackState != nil {
|
||||
b.SetState(s.ctx.BackState, bot.EditMessage)
|
||||
}
|
||||
|
||||
case data == "edit_text":
|
||||
// Начинаем редактирование текста
|
||||
s.WaitingForTextEdit = true
|
||||
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{
|
||||
Row(Button("« Отмена", "cancel_edit")),
|
||||
})
|
||||
|
||||
s.ctx.InputMode = inputModeText
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
case data == "add_button":
|
||||
// Начинаем процесс добавления кнопки
|
||||
s.WaitingForButtonText = true
|
||||
s.PendingButtonType = "invite"
|
||||
|
||||
s.ShowControlPanel(b, `<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button("✓ Ссылка на канал", "button_type_invite"),
|
||||
Button("Своя ссылка", "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
|
||||
s.ctx.InputMode = inputModeButtonText
|
||||
s.ctx.PendingButtonType = "invite"
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
case data == "add_media":
|
||||
// Начинаем процесс добавления медиа
|
||||
s.WaitingForMedia = true
|
||||
|
||||
s.ShowControlPanel(b, `<b>+ Добавить медиа</b>
|
||||
|
||||
Отправьте <b>фото, видео или GIF</b>
|
||||
|
||||
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_media")),
|
||||
})
|
||||
|
||||
s.ctx.InputMode = inputModeMedia
|
||||
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
|
||||
case data == "delete_media":
|
||||
// Удаляем медиа
|
||||
s.MediaType = ""
|
||||
s.MediaFileID = ""
|
||||
s.MediaChanged = true
|
||||
s.UpdateCreativePreview(b)
|
||||
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
|
||||
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" || lastButton.URL == inviteLinkPlaceholder {
|
||||
s.Buttons = s.Buttons[:len(s.Buttons)-1]
|
||||
s.UpdateCreativePreview(b)
|
||||
}
|
||||
}
|
||||
s.WaitingForButtonText = false
|
||||
s.WaitingForButtonURL = false
|
||||
s.PendingButtonType = ""
|
||||
s.CurrentButtonText = nil
|
||||
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
|
||||
case data == "button_type_invite":
|
||||
s.PendingButtonType = "invite"
|
||||
s.ShowControlPanel(b, `<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button("✓ Ссылка на канал", "button_type_invite"),
|
||||
Button("Своя ссылка", "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
|
||||
case data == "button_type_custom":
|
||||
s.PendingButtonType = "custom"
|
||||
s.ShowControlPanel(b, `<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button("Ссылка на канал", "button_type_invite"),
|
||||
Button("✓ Своя ссылка", "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
|
||||
case data == "cancel_edit":
|
||||
// Отменяем редактирование текста
|
||||
s.WaitingForTextEdit = false
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
|
||||
case data == "cancel_media":
|
||||
// Отменяем добавление медиа
|
||||
s.WaitingForMedia = false
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
|
||||
s.ctx.MediaType = ""
|
||||
s.ctx.MediaFileID = ""
|
||||
s.ctx.MediaChanged = true
|
||||
s.ctx.UpdateCreativePreview(b)
|
||||
s.Enter(b, bot.EditMessage)
|
||||
case data == "confirm_create":
|
||||
// Создаём креатив
|
||||
s.createCreative(b)
|
||||
|
||||
s.ctx.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)
|
||||
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
}
|
||||
}
|
||||
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 *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
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
|
||||
}
|
||||
|
||||
// Шаг 1: Ждем пересылки креатива (любое сообщение)
|
||||
if s.WaitingForCreative {
|
||||
// Извлекаем данные из сообщения
|
||||
s.extractCreativeFromMessage(u.Message)
|
||||
|
||||
// Генерируем название из текста или дефолтное
|
||||
if s.Name == nil {
|
||||
name := s.generateCreativeName()
|
||||
s.Name = &name
|
||||
}
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
|
||||
s.WaitingForCreative = false
|
||||
s.CreativeMessageSent = true
|
||||
|
||||
// Показываем превью + панель с предложением добавить
|
||||
s.showCreativeConfirmation(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
|
||||
s.MediaChanged = true
|
||||
handled = true
|
||||
} else if u.Message.Video != nil {
|
||||
s.MediaType = "video"
|
||||
s.MediaFileID = u.Message.Video.FileID
|
||||
s.MediaChanged = true
|
||||
handled = true
|
||||
} else if u.Message.Animation != nil {
|
||||
s.MediaType = "animation"
|
||||
s.MediaFileID = u.Message.Animation.FileID
|
||||
s.MediaChanged = true
|
||||
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)
|
||||
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка добавления кнопки - текст кнопки
|
||||
if s.WaitingForButtonText {
|
||||
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.CurrentButtonText = &buttonText
|
||||
s.WaitingForButtonText = false
|
||||
s.ctx.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
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
|
||||
}
|
||||
|
||||
if s.PendingButtonType == "custom" {
|
||||
s.WaitingForButtonURL = true
|
||||
s.Buttons = append(s.Buttons, InlineButton{
|
||||
Text: buttonText,
|
||||
URL: "https://example.com",
|
||||
})
|
||||
s.UpdateCreativePreview(b)
|
||||
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{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
} else {
|
||||
s.Buttons = append(s.Buttons, InlineButton{
|
||||
Text: buttonText,
|
||||
URL: inviteLinkPlaceholder,
|
||||
})
|
||||
s.UpdateCreativePreview(b)
|
||||
s.PendingButtonType = ""
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Обработка добавления кнопки - URL
|
||||
if s.WaitingForButtonURL {
|
||||
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)
|
||||
|
||||
url := u.Message.Text
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(url, "https://") {
|
||||
s.ShowControlPanel(b, `<b>❌ Неверный формат URL</b>
|
||||
|
||||
URL должен начинаться с <code>https://</code>
|
||||
|
||||
Попробуйте ещё раз.`, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
if !s.ctx.IsValidButtonURL(url, true) {
|
||||
s.ctx.ShowInvalidButtonURLPanel(b)
|
||||
return
|
||||
}
|
||||
|
||||
if len(s.Buttons) > 0 {
|
||||
s.Buttons[len(s.Buttons)-1].URL = url
|
||||
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.WaitingForButtonURL = false
|
||||
s.CurrentButtonText = nil
|
||||
s.PendingButtonType = ""
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Если ждем редактирования текста креатива
|
||||
if s.WaitingForTextEdit && u.Message.Text != "" {
|
||||
text := s.GetFormattedText(u.Message)
|
||||
s.Text = &text
|
||||
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
|
||||
s.WaitingForTextEdit = false
|
||||
s.UpdateCreativePreview(b)
|
||||
|
||||
if s.CreativeMessageSent {
|
||||
s.showCreativeConfirmation(b)
|
||||
} else {
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Если креатив уже показан и пользователь вводит текст - это изменение названия
|
||||
if s.CreativeMessageSent && u.Message.Text != "" {
|
||||
newName := u.Message.Text
|
||||
|
||||
// Ограничение 200 символов
|
||||
runes := []rune(newName)
|
||||
if len(runes) > 200 {
|
||||
newName = string(runes[:200])
|
||||
}
|
||||
|
||||
s.Name = &newName
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
|
||||
// Обновляем панель с новым названием
|
||||
s.showCreativeConfirmation(b)
|
||||
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 *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {}
|
||||
|
||||
// extractCreativeFromMessage извлекает данные креатива из сообщения
|
||||
func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) {
|
||||
// Извлекаем текст с форматированием (GetFormattedText теперь поддерживает и text и caption)
|
||||
func (s *AddCreativeInput) Exit() {}
|
||||
|
||||
func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) {
|
||||
if message.Text != "" || message.Caption != "" {
|
||||
text := s.GetFormattedText(message)
|
||||
text := ui.FormatMessageHTML(message)
|
||||
if text != "" {
|
||||
s.Text = &text
|
||||
}
|
||||
}
|
||||
|
||||
// Извлекаем медиа
|
||||
if message.Photo != nil && len(message.Photo) > 0 {
|
||||
photo := message.Photo[len(message.Photo)-1]
|
||||
s.MediaType = "photo"
|
||||
s.MediaFileID = photo.FileID
|
||||
s.MediaChanged = true
|
||||
} else if message.Video != nil {
|
||||
s.MediaType = "video"
|
||||
s.MediaFileID = message.Video.FileID
|
||||
s.MediaChanged = true
|
||||
} else if message.Animation != nil {
|
||||
s.MediaType = "animation"
|
||||
s.MediaFileID = message.Animation.FileID
|
||||
s.MediaChanged = true
|
||||
}
|
||||
s.SetMediaFromMessage(message)
|
||||
|
||||
// Извлекаем кнопки из ReplyMarkup
|
||||
if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 {
|
||||
for _, row := range message.ReplyMarkup.InlineKeyboard {
|
||||
for _, button := range row {
|
||||
// Берем только кнопки с URL
|
||||
if button.URL != "" {
|
||||
s.Buttons = append(s.Buttons, InlineButton{
|
||||
Text: button.Text,
|
||||
@@ -466,10 +320,8 @@ func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) {
|
||||
}
|
||||
}
|
||||
|
||||
// generateCreativeName генерирует название из текста или дефолтное
|
||||
func (s *AddCreative) generateCreativeName() string {
|
||||
func (s *AddCreativeCtx) generateCreativeName() string {
|
||||
if s.Text != nil && *s.Text != "" {
|
||||
// Удаляем ВСЕ HTML теги с помощью regex
|
||||
re := regexp.MustCompile(`<[^>]*>`)
|
||||
cleanText := re.ReplaceAllString(*s.Text, "")
|
||||
|
||||
@@ -485,119 +337,28 @@ func (s *AddCreative) generateCreativeName() string {
|
||||
return "Новый креатив"
|
||||
}
|
||||
|
||||
// escapeHTML экранирует HTML-символы для безопасного вывода
|
||||
func escapeHTML(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
// buildEditorButtons создает кнопки для панели редактирования креатива
|
||||
func (s *AddCreative) buildEditorButtons(confirmText, cancelCallback string) [][]echotron.InlineKeyboardButton {
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
|
||||
// Кнопки редактирования
|
||||
buttons = append(buttons, Row(
|
||||
Button("✎ Текст", "edit_text"),
|
||||
Button("+ Медиа", "add_media"),
|
||||
))
|
||||
buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button")))
|
||||
|
||||
// Кнопка удаления медиа (если медиа добавлено)
|
||||
if s.MediaFileID != "" {
|
||||
buttons = append(buttons, Row(
|
||||
Button("⌫ Убрать медиа", "delete_media"),
|
||||
))
|
||||
}
|
||||
|
||||
// Кнопки удаления добавленных кнопок
|
||||
if len(s.Buttons) > 0 {
|
||||
var row []echotron.InlineKeyboardButton
|
||||
for i, btn := range s.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)
|
||||
}
|
||||
}
|
||||
|
||||
// Финальные кнопки
|
||||
if s.Text != nil {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← "+cancelCallback, "cancel"),
|
||||
Button(confirmText, "confirm_create"),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← "+cancelCallback, "cancel"),
|
||||
))
|
||||
}
|
||||
|
||||
return buttons
|
||||
}
|
||||
|
||||
// showCreativeConfirmation показывает превью креатива + панель с предложением добавить
|
||||
func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) {
|
||||
|
||||
// 1. Отправляем/обновляем превью креатива
|
||||
func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) {
|
||||
if s.CreativeMessageID == nil {
|
||||
s.SendCreativePreview(b)
|
||||
} else {
|
||||
s.UpdateCreativePreview(b)
|
||||
}
|
||||
|
||||
// 2. Формируем текст панели управления
|
||||
// Экранируем HTML-символы в названии для безопасного вывода
|
||||
escapedName := escapeHTML(*s.Name)
|
||||
confirmText := fmt.Sprintf(`<b>➕ Добавляем этот креатив?</b>
|
||||
|
||||
<b>Название креатива:</b> %s
|
||||
|
||||
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>`, escapedName)
|
||||
|
||||
// 3. Показываем панель с кнопками редактирования + сохранения
|
||||
buttons := s.buildEditorButtons("✓ Сохранить", "Отмена")
|
||||
escapedName := ui.EscapeHTML(*s.Name)
|
||||
confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName)
|
||||
buttons := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel")
|
||||
s.ShowControlPanel(b, confirmText, buttons)
|
||||
}
|
||||
|
||||
// sendCreativeAndPanel отправляет превью креатива и панель управления
|
||||
func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) {
|
||||
|
||||
// 1. Отправляем превью креатива
|
||||
s.SendCreativePreview(b)
|
||||
|
||||
// 2. Отправляем панель управления
|
||||
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
|
||||
}
|
||||
|
||||
func (s *AddCreative) createCreative(b *bot.Bot) {
|
||||
// JWT уже создан в Bot.Update(), просто берем из сессии
|
||||
jwt := b.Session.JWT
|
||||
if jwt == "" {
|
||||
log.Error().Msg("JWT is empty in session")
|
||||
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start login", Keyboard())
|
||||
return
|
||||
}
|
||||
|
||||
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("✓ Попробовать снова", "Отмена")
|
||||
s.ShowControlPanel(b, "<b>❌ Не удалось загрузить медиа</b>\n\nПопробуйте удалить медиа и добавить заново.", buttons)
|
||||
buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")
|
||||
s.ShowControlPanel(b, msgDownloadMediaError, buttons)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -610,84 +371,41 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
|
||||
})
|
||||
}
|
||||
|
||||
// Логируем что отправляем
|
||||
log.Info().
|
||||
Str("name", *s.Name).
|
||||
Str("text", *s.Text).
|
||||
Int("buttons_count", len(buttons)).
|
||||
Interface("buttons", buttons).
|
||||
Str("media_type", s.MediaType).
|
||||
Msg("Creating creative with data")
|
||||
|
||||
// Создаём креатив через API
|
||||
creative, err := b.Backend.CreateCreative(
|
||||
context.Background(),
|
||||
jwt,
|
||||
s.WorkspaceID,
|
||||
s.ProjectID,
|
||||
backend.CreateCreativeInput{
|
||||
Name: *s.Name,
|
||||
Text: *s.Text,
|
||||
MediaType: s.MediaType,
|
||||
MediaFileID: s.MediaFileID,
|
||||
MediaData: mediaData,
|
||||
Buttons: buttons,
|
||||
},
|
||||
)
|
||||
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 = `<b>❌ Ошибка валидации</b>
|
||||
|
||||
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
<b>Пример правильного текста:</b>
|
||||
<i>Присоединяйтесь к нашему каналу!
|
||||
https://t.me/+AbCdEfGhIjKlMn</i>
|
||||
|
||||
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>`
|
||||
userMsg = msgInviteLinkRequired
|
||||
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
|
||||
userMsg = `<b>❌ Слишком много ссылок</b>
|
||||
|
||||
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
|
||||
У вас их несколько. Удалите лишние.
|
||||
|
||||
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>`
|
||||
userMsg = msgInviteLinkTooMany
|
||||
} else {
|
||||
userMsg = `<b>❌ Ошибка создания</b>
|
||||
|
||||
Не удалось создать креатив.
|
||||
Попробуйте ещё раз или вернитесь назад.`
|
||||
userMsg = msgCreateError
|
||||
}
|
||||
|
||||
// Показываем ошибку через панель управления, чтобы не ломать порядок сообщений
|
||||
buttons := s.buildEditorButtons("✓ Попробовать снова", "Отмена")
|
||||
buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")
|
||||
s.ShowControlPanel(b, userMsg, buttons)
|
||||
return
|
||||
}
|
||||
|
||||
// Успех! Показываем сообщение и возвращаемся
|
||||
successText := `<b>✅ Креатив успешно создан!</b>
|
||||
|
||||
📄 <b>Название:</b> ` + creative.Name
|
||||
|
||||
successText := fmt.Sprintf(msgCreativeCreated, creative.Name)
|
||||
if len(s.Buttons) > 0 {
|
||||
successText += fmt.Sprintf(`
|
||||
🔘 <b>Кнопок добавлено:</b> %d`, len(s.Buttons))
|
||||
successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons))
|
||||
}
|
||||
|
||||
b.SendNew(successText, Keyboard())
|
||||
|
||||
// Возвращаемся на экран креативов
|
||||
if s.BackState != nil {
|
||||
b.SetState(s.BackState, bot.NewMessage)
|
||||
}
|
||||
|
||||
@@ -58,3 +58,5 @@ func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddProject) Exit() {}
|
||||
|
||||
@@ -300,3 +300,5 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *AddPurchase) Exit() {}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"example.com/m/adapter/backend"
|
||||
"example.com/m/bot"
|
||||
"example.com/m/ui"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -36,7 +37,8 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
// Заполняем поля из загруженного креатива
|
||||
s.Name = &creative.Name
|
||||
if creative.Text != "" {
|
||||
s.Text = &creative.Text
|
||||
sanitizedText := ui.SanitizeHTML(creative.Text)
|
||||
s.Text = &sanitizedText
|
||||
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
|
||||
} else {
|
||||
log.Info().Msg("Creative text is empty from backend")
|
||||
@@ -48,6 +50,7 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
|
||||
}
|
||||
s.MediaChanged = false
|
||||
s.ClearInputMode()
|
||||
|
||||
log.Info().
|
||||
Str("creative_id", s.CreativeID).
|
||||
@@ -77,47 +80,17 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
}
|
||||
|
||||
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{
|
||||
Row(Button("« Отмена редактирования", "cancel_edit")),
|
||||
})
|
||||
s.InputMode = inputModeText
|
||||
s.ShowTextEditPanel(b)
|
||||
|
||||
case data == "add_button":
|
||||
s.WaitingForButtonText = true
|
||||
s.InputMode = inputModeButtonText
|
||||
s.PendingButtonType = "invite"
|
||||
s.ShowControlPanel(b, `<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button("✓ Ссылка на канал", "button_type_invite"),
|
||||
Button("Своя ссылка", "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
s.ShowAddButtonPanel(b)
|
||||
|
||||
case data == "add_media":
|
||||
s.WaitingForMedia = true
|
||||
s.ShowControlPanel(b, `<b>+ Добавить медиа</b>
|
||||
|
||||
Отправьте <b>фото, видео или GIF</b>
|
||||
|
||||
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_media")),
|
||||
})
|
||||
s.InputMode = inputModeMedia
|
||||
s.ShowMediaPanel(b)
|
||||
|
||||
case data == "delete_media":
|
||||
s.MediaType = ""
|
||||
@@ -128,57 +101,21 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
|
||||
|
||||
case data == "cancel_add_button":
|
||||
// Отменяем добавление кнопки
|
||||
if s.WaitingForButtonURL && len(s.Buttons) > 0 {
|
||||
lastButton := s.Buttons[len(s.Buttons)-1]
|
||||
if lastButton.URL == "https://example.com" || lastButton.URL == inviteLinkPlaceholder {
|
||||
s.Buttons = s.Buttons[:len(s.Buttons)-1]
|
||||
s.UpdateCreativePreview(b)
|
||||
}
|
||||
}
|
||||
s.WaitingForButtonText = false
|
||||
s.WaitingForButtonURL = false
|
||||
s.PendingButtonType = ""
|
||||
s.CurrentButtonText = nil
|
||||
s.CancelPendingButton(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
|
||||
case data == "button_type_invite":
|
||||
s.PendingButtonType = "invite"
|
||||
s.ShowControlPanel(b, `<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button("✓ Ссылка на канал", "button_type_invite"),
|
||||
Button("Своя ссылка", "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
s.InputMode = inputModeButtonText
|
||||
s.ShowAddButtonPanel(b)
|
||||
|
||||
case data == "button_type_custom":
|
||||
s.PendingButtonType = "custom"
|
||||
s.ShowControlPanel(b, `<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button("Ссылка на канал", "button_type_invite"),
|
||||
Button("✓ Своя ссылка", "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
s.InputMode = inputModeButtonText
|
||||
s.ShowAddButtonPanel(b)
|
||||
|
||||
case data == "cancel_edit", data == "cancel_media":
|
||||
s.WaitingForMedia = false
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(b)
|
||||
|
||||
case data == "confirm_save":
|
||||
@@ -225,146 +162,80 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||||
return
|
||||
}
|
||||
|
||||
// Обрабатываем добавление медиа
|
||||
if s.WaitingForMedia {
|
||||
handled := false
|
||||
if u.Message.Photo != nil && len(u.Message.Photo) > 0 {
|
||||
photos := u.Message.Photo
|
||||
largestPhoto := photos[len(photos)-1]
|
||||
s.MediaType = "photo"
|
||||
s.MediaFileID = largestPhoto.FileID
|
||||
s.MediaChanged = true
|
||||
s.WaitingForMedia = false
|
||||
handled = true
|
||||
|
||||
} else if u.Message.Video != nil {
|
||||
s.MediaType = "video"
|
||||
s.MediaFileID = u.Message.Video.FileID
|
||||
s.MediaChanged = true
|
||||
s.WaitingForMedia = false
|
||||
handled = true
|
||||
|
||||
} else if u.Message.Animation != nil {
|
||||
s.MediaType = "animation"
|
||||
s.MediaFileID = u.Message.Animation.FileID
|
||||
s.MediaChanged = true
|
||||
s.WaitingForMedia = false
|
||||
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.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
switch s.InputMode {
|
||||
case inputModeMedia:
|
||||
if !s.SetMediaFromMessage(u.Message) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Обрабатываем ввод текста кнопки
|
||||
if s.WaitingForButtonText && u.Message.Text != "" {
|
||||
buttonText := u.Message.Text
|
||||
s.CurrentButtonText = &buttonText
|
||||
s.WaitingForButtonText = false
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
s.UpdateCreativePreview(b)
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(b)
|
||||
return
|
||||
case inputModeButtonText:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
buttonText := u.Message.Text
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
// Создаём кнопку с placeholder URL
|
||||
if s.PendingButtonType == "custom" {
|
||||
s.WaitingForButtonURL = true
|
||||
s.Buttons = append(s.Buttons, InlineButton{
|
||||
Text: buttonText,
|
||||
URL: "https://example.com",
|
||||
})
|
||||
s.AddCustomButtonPlaceholder(buttonText)
|
||||
s.UpdateCreativePreview(b)
|
||||
s.ShowControlPanel(b, `<b>✧ Введите URL для кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
<code>https://example.com</code>`, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
s.InputMode = inputModeButtonURL
|
||||
s.ShowButtonURLPanel(b)
|
||||
} else {
|
||||
s.Buttons = append(s.Buttons, InlineButton{
|
||||
Text: buttonText,
|
||||
URL: inviteLinkPlaceholder,
|
||||
})
|
||||
s.AddInviteButton(buttonText)
|
||||
s.UpdateCreativePreview(b)
|
||||
s.PendingButtonType = ""
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(b)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Обрабатываем ввод URL кнопки
|
||||
if s.WaitingForButtonURL && u.Message.Text != "" {
|
||||
url := strings.TrimSpace(u.Message.Text)
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
case inputModeButtonURL:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
url := strings.TrimSpace(u.Message.Text)
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
// Простая валидация URL
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
s.ShowControlPanel(b, `<b>❌ Неверный формат URL</b>
|
||||
|
||||
URL должен начинаться с <code>http://</code> или <code>https://</code>
|
||||
|
||||
Попробуйте ещё раз:`, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
if !s.IsValidButtonURL(url, true) {
|
||||
s.ShowInvalidButtonURLPanel(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем URL последней кнопки
|
||||
if len(s.Buttons) > 0 {
|
||||
s.Buttons[len(s.Buttons)-1].URL = url
|
||||
}
|
||||
s.UpdateLastButtonURL(url)
|
||||
|
||||
s.WaitingForButtonURL = false
|
||||
s.CurrentButtonText = nil
|
||||
s.ClearInputMode()
|
||||
s.PendingButtonType = ""
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Обрабатываем редактирование текста креатива
|
||||
if !s.WaitingForButtonText && !s.WaitingForButtonURL && !s.WaitingForMedia && u.Message.Text != "" {
|
||||
text := s.GetFormattedText(u.Message)
|
||||
case inputModeText:
|
||||
if u.Message.Text == "" {
|
||||
return
|
||||
}
|
||||
text := ui.FormatMessageHTML(u.Message)
|
||||
s.Text = &text
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
s.ClearInputMode()
|
||||
s.showManagementPanelWithDelete(b)
|
||||
return
|
||||
}
|
||||
|
||||
// Обрабатываем имя креатива (если нет других состояний)
|
||||
if u.Message.Text != "" {
|
||||
text := s.GetFormattedText(u.Message)
|
||||
text := ui.FormatMessageHTML(u.Message)
|
||||
s.Text = &text
|
||||
|
||||
// Удаляем сообщение пользователя
|
||||
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to delete user message")
|
||||
}
|
||||
s.DeleteUserMessage(b, u.Message.ID)
|
||||
|
||||
s.UpdateCreativePreview(b)
|
||||
s.showManagementPanelWithDelete(b)
|
||||
@@ -373,6 +244,8 @@ URL должен начинаться с <code>http://</code> или <code>https
|
||||
|
||||
func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *CreativeDetails) Exit() {}
|
||||
|
||||
func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) {
|
||||
var confirmText, confirmCallback string
|
||||
|
||||
|
||||
@@ -2,10 +2,10 @@ package screens
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"example.com/m/bot"
|
||||
"example.com/m/ui"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
@@ -28,12 +28,11 @@ type CreativeEditorFields struct {
|
||||
MediaFileID string // File ID из Telegram
|
||||
MediaChanged bool
|
||||
|
||||
// Режим ввода
|
||||
InputMode string
|
||||
|
||||
// Состояние для добавления кнопки
|
||||
WaitingForButtonText bool
|
||||
WaitingForButtonURL bool
|
||||
CurrentButtonText *string
|
||||
WaitingForMedia bool
|
||||
PendingButtonType string
|
||||
PendingButtonType string
|
||||
|
||||
// ID сообщения с креативом (которое постоянно обновляется)
|
||||
CreativeMessageID *int
|
||||
@@ -41,115 +40,108 @@ type CreativeEditorFields struct {
|
||||
ControlPanelMessageID *int
|
||||
|
||||
// Предыдущее состояние для определения изменения типа сообщения
|
||||
previousHasMedia bool
|
||||
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 {
|
||||
var result string
|
||||
|
||||
if e.Text != nil && *e.Text != "" {
|
||||
text := strings.TrimSpace(*e.Text)
|
||||
|
||||
// Проверяем что текст не пустой
|
||||
if text == "" {
|
||||
log.Info().Str("text", *e.Text).Msg("GetCreativeText: text is empty, using default")
|
||||
return result + `<i>💭 Я твой креатив
|
||||
|
||||
У меня пока нет текста, добавь его ниже.
|
||||
|
||||
</i>`
|
||||
}
|
||||
|
||||
// Заменяем пригласительные ссылки с классом tg-link на preview-ссылку
|
||||
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
|
||||
replacementCount := 0
|
||||
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]
|
||||
log.Info().
|
||||
Int("replacement_num", replacementCount).
|
||||
Str("linkText_before_sanitize", linkText).
|
||||
Msg("Processing tg-link")
|
||||
|
||||
// ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности
|
||||
// Это нужно для старых креативов с неправильным HTML
|
||||
tagsRemoved := 0
|
||||
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
|
||||
}
|
||||
// Удаляем тег
|
||||
removedTag := linkText[openTag : openTag+closeTag+1]
|
||||
linkText = linkText[:openTag] + linkText[openTag+closeTag+1:]
|
||||
tagsRemoved++
|
||||
log.Info().
|
||||
Str("removed_tag", removedTag).
|
||||
Str("linkText_after", linkText).
|
||||
Int("tags_removed_total", tagsRemoved).
|
||||
Msg("Removed HTML tag from link text")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Str("linkText_after_sanitize", linkText).
|
||||
Int("tags_removed", tagsRemoved).
|
||||
Str("replacement", replacement).
|
||||
Msg("Sanitized link text")
|
||||
|
||||
text = text[:startIdx] + replacement + text[endIdx+len("</a>"):]
|
||||
replacementCount++
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Int("replacements_made", replacementCount).
|
||||
Str("final_text", text).
|
||||
Msg("Finished processing tg-links")
|
||||
|
||||
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках)
|
||||
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
|
||||
|
||||
log.Info().Str("text", *e.Text).Msg("GetCreativeText: returning creative text")
|
||||
return result + text
|
||||
if e.Text == nil {
|
||||
return msgCreativeDefault
|
||||
}
|
||||
log.Info().Bool("text_is_nil", e.Text == nil).Msg("GetCreativeText: returning default text")
|
||||
return result + `<i>💭 Я твой креатив
|
||||
|
||||
У меня пока нет текста, добавь его ниже.
|
||||
text := strings.TrimSpace(ui.SanitizeHTML(*e.Text))
|
||||
if text == "" {
|
||||
return msgCreativeDefault
|
||||
}
|
||||
|
||||
</i>`
|
||||
// Заменяем пригласительные ссылки с классом 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 возвращает кнопки для превью креатива
|
||||
@@ -178,14 +170,25 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
|
||||
creativeButtons := e.GetCreativeButtons()
|
||||
|
||||
// Создаем клавиатуру с пустым массивом (не nil!)
|
||||
keyboard := echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: creativeButtons,
|
||||
}
|
||||
if len(creativeButtons) == 0 {
|
||||
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
|
||||
}
|
||||
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 != "" {
|
||||
@@ -228,15 +231,8 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media")
|
||||
// Fallback to text message - используем прямой SendMessage
|
||||
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
ParseMode: echotron.HTML,
|
||||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||||
IsDisabled: true,
|
||||
},
|
||||
})
|
||||
if err == nil && res.Result != nil {
|
||||
msgID = res.Result.ID
|
||||
if id, err := sendText(); err == nil {
|
||||
msgID = id
|
||||
}
|
||||
} else if res.Result != nil {
|
||||
msgID = res.Result.ID
|
||||
@@ -244,26 +240,19 @@ func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
|
||||
} else {
|
||||
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
|
||||
// чтобы не затронуть LastMessageID и не затронуть cleanup
|
||||
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
|
||||
ReplyMarkup: keyboard,
|
||||
ParseMode: echotron.HTML,
|
||||
LinkPreviewOptions: echotron.LinkPreviewOptions{
|
||||
IsDisabled: true,
|
||||
},
|
||||
})
|
||||
id, err := sendText()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to send creative preview")
|
||||
return
|
||||
}
|
||||
if res.Result != nil {
|
||||
msgID = res.Result.ID
|
||||
}
|
||||
msgID = id
|
||||
}
|
||||
|
||||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||||
e.CreativeMessageID = &msgID
|
||||
e.previousHasMedia = e.MediaFileID != ""
|
||||
log.Info().Int("creative_msg_id", *e.CreativeMessageID).Bool("has_media", e.MediaFileID != "").Msg("Creative preview sent")
|
||||
e.previousMediaType = e.MediaType
|
||||
e.previousMediaFileID = e.MediaFileID
|
||||
}
|
||||
|
||||
// UpdateCreativePreview обновляет превью креатива
|
||||
@@ -277,17 +266,8 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
|
||||
|
||||
currentHasMedia := e.MediaFileID != ""
|
||||
|
||||
log.Info().
|
||||
Int("creative_msg_id", *e.CreativeMessageID).
|
||||
Bool("has_media", currentHasMedia).
|
||||
Bool("previous_has_media", e.previousHasMedia).
|
||||
Str("media_type", e.MediaType).
|
||||
Msg("Updating creative preview")
|
||||
|
||||
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
|
||||
if currentHasMedia != e.previousHasMedia {
|
||||
log.Info().Msg("Message type changed, recreating both messages")
|
||||
|
||||
// Удаляем оба сообщения
|
||||
if e.CreativeMessageID != nil {
|
||||
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
|
||||
@@ -307,29 +287,84 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
|
||||
creativeText := e.GetCreativeText()
|
||||
creativeButtons := e.GetCreativeButtons()
|
||||
|
||||
keyboard := echotron.InlineKeyboardMarkup{
|
||||
InlineKeyboard: creativeButtons,
|
||||
}
|
||||
if len(creativeButtons) == 0 {
|
||||
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
|
||||
}
|
||||
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 != "" {
|
||||
_, err := b.EditMessageCaption(
|
||||
msgID,
|
||||
&echotron.MessageCaptionOptions{
|
||||
Caption: creativeText,
|
||||
ParseMode: echotron.HTML,
|
||||
ReplyMarkup: keyboard,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to edit media caption")
|
||||
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 {
|
||||
e.previousHasMedia = currentHasMedia
|
||||
_, 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 {
|
||||
// Текстовое сообщение - редактируем текст
|
||||
@@ -345,11 +380,88 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to edit message text")
|
||||
logEditError("Failed to edit message text", err)
|
||||
} else {
|
||||
e.previousHasMedia = currentHasMedia
|
||||
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 показывает панель управления креативом
|
||||
@@ -358,10 +470,13 @@ func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
|
||||
// cancelCallback - callback для кнопки отмены
|
||||
// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
|
||||
func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
|
||||
text := `<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
|
||||
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
|
||||
|
||||
// Блок редактирования контента
|
||||
@@ -369,7 +484,6 @@ func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText
|
||||
Button("✎ Текст", "edit_text"),
|
||||
Button("+ Медиа", "add_media"),
|
||||
))
|
||||
// Добавление кнопки сразу под основным блоком
|
||||
buttons = append(buttons, Row(Button("+ Добавить кнопку", "add_button")))
|
||||
|
||||
// Кнопка удаления медиа (если медиа добавлено)
|
||||
@@ -405,18 +519,19 @@ func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText
|
||||
}
|
||||
|
||||
// Финальные кнопки
|
||||
cancelLabel := "← " + cancelText
|
||||
if e.Text != nil {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", cancelCallback),
|
||||
Button(confirmButtonText, confirmCallback),
|
||||
Button(cancelLabel, cancelCallback),
|
||||
Button(confirmText, confirmCallback),
|
||||
))
|
||||
} else {
|
||||
buttons = append(buttons, Row(
|
||||
Button("← Назад", cancelCallback),
|
||||
Button(cancelLabel, cancelCallback),
|
||||
))
|
||||
}
|
||||
|
||||
e.ShowControlPanel(b, text, buttons)
|
||||
return buttons
|
||||
}
|
||||
|
||||
// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
|
||||
@@ -428,211 +543,14 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
|
||||
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
|
||||
controlPanelMsgID := b.LastMessageID
|
||||
e.ControlPanelMessageID = &controlPanelMsgID
|
||||
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Control panel sent")
|
||||
} else {
|
||||
// Редактируем существующую панель
|
||||
log.Info().Int("control_panel_msg_id", *e.ControlPanelMessageID).Msg("Editing control panel")
|
||||
|
||||
// Временно заменяем LastMessageID на ID панели управления
|
||||
oldLastMessageID := b.LastMessageID
|
||||
b.LastMessageID = *e.ControlPanelMessageID
|
||||
defer func() { b.LastMessageID = oldLastMessageID }()
|
||||
|
||||
keyboard := Keyboard(buttons...)
|
||||
b.Edit(text, keyboard)
|
||||
|
||||
// Восстанавливаем оригинальный LastMessageID
|
||||
b.LastMessageID = oldLastMessageID
|
||||
log.Info().Msg("Control panel updated")
|
||||
}
|
||||
}
|
||||
|
||||
// GetFormattedText извлекает форматированный текст из сообщения с entities
|
||||
func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) string {
|
||||
// Определяем откуда брать текст и entities
|
||||
var text string
|
||||
var entities []*echotron.MessageEntity
|
||||
|
||||
if message.Text != "" {
|
||||
text = message.Text
|
||||
entities = message.Entities
|
||||
} else if message.Caption != "" {
|
||||
text = message.Caption
|
||||
entities = message.CaptionEntities
|
||||
}
|
||||
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Если нет entities - возвращаем текст как есть
|
||||
if len(entities) == 0 {
|
||||
return text
|
||||
}
|
||||
|
||||
type tagSpan struct {
|
||||
open string
|
||||
close string
|
||||
start int
|
||||
end int
|
||||
len int
|
||||
}
|
||||
|
||||
// Преобразуем текст в руны для правильной работы с UTF-8
|
||||
runes := []rune(text)
|
||||
|
||||
// Сначала находим все url entities, чтобы исключить перекрывающиеся форматирования
|
||||
urlRanges := make([][2]int, 0)
|
||||
// Также отмечаем диапазоны, которые нужно полностью исключить из текста (простые url ссылки)
|
||||
skipRanges := make(map[[2]int]bool)
|
||||
|
||||
for _, entity := range entities {
|
||||
if entity.Type == "url" || entity.Type == "text_link" {
|
||||
urlRanges = append(urlRanges, [2]int{entity.Offset, entity.Offset + entity.Length})
|
||||
// Для простых url (не text_link) с пригласительными ссылками - пропускаем текст URL
|
||||
// Создаем пустой <a class="tg-link"></a>, а сам URL не сохраняем
|
||||
if entity.Type == "url" {
|
||||
entityText := ""
|
||||
if entity.Offset+entity.Length <= len(runes) {
|
||||
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
|
||||
}
|
||||
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
|
||||
skipRanges[[2]int{entity.Offset, entity.Offset + entity.Length}] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, находится ли entity внутри URL
|
||||
isInsideURL := func(offset, length int) bool {
|
||||
for _, urlRange := range urlRanges {
|
||||
// Если entity полностью внутри URL (но не сам URL)
|
||||
if offset > urlRange[0] && offset+length <= urlRange[1] {
|
||||
return true
|
||||
}
|
||||
// Если entity пересекается с URL
|
||||
if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var spans []tagSpan
|
||||
for _, entity := range entities {
|
||||
entityText := ""
|
||||
if entity.Offset+entity.Length <= len(runes) {
|
||||
entityText = string(runes[entity.Offset : entity.Offset+entity.Length])
|
||||
}
|
||||
|
||||
// Пропускаем форматирование (bold, italic, etc), если оно находится внутри URL
|
||||
if entity.Type != "url" && entity.Type != "text_link" && isInsideURL(entity.Offset, entity.Length) {
|
||||
continue
|
||||
}
|
||||
|
||||
var openTag, closeTag string
|
||||
switch entity.Type {
|
||||
case "bold":
|
||||
openTag, closeTag = "<b>", "</b>"
|
||||
case "italic":
|
||||
openTag, closeTag = "<i>", "</i>"
|
||||
case "underline":
|
||||
openTag, closeTag = "<u>", "</u>"
|
||||
case "strikethrough":
|
||||
openTag, closeTag = "<s>", "</s>"
|
||||
case "code":
|
||||
openTag, closeTag = "<code>", "</code>"
|
||||
case "pre":
|
||||
openTag, closeTag = "<pre>", "</pre>"
|
||||
case "url":
|
||||
// Конвертируем простые URL в HTML-ссылки
|
||||
// Для пригласительных ссылок (t.me/+ или t.me/joinchat/) используем пустой тег
|
||||
if strings.Contains(entityText, "t.me/+") || strings.Contains(entityText, "t.me/joinchat/") {
|
||||
// Пригласительная ссылка - создаем пустой <a class="tg-link"></a>
|
||||
// Текст ссылки будет пропущен при рендеринге (см. skipRanges)
|
||||
openTag = `<a class="tg-link">`
|
||||
closeTag = "</a>"
|
||||
} else {
|
||||
// Обычная ссылка - оставляем как есть (ссылка на себя)
|
||||
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
|
||||
closeTag = "</a>"
|
||||
}
|
||||
case "text_link":
|
||||
if entity.URL != "" {
|
||||
// Проверяем, является ли URL пригласительной ссылкой
|
||||
if strings.Contains(entity.URL, "t.me/+") || strings.Contains(entity.URL, "t.me/joinchat/") {
|
||||
openTag = `<a class="tg-link">`
|
||||
closeTag = "</a>"
|
||||
} else {
|
||||
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
|
||||
closeTag = "</a>"
|
||||
}
|
||||
}
|
||||
default:
|
||||
_ = entityText
|
||||
}
|
||||
|
||||
if openTag != "" && closeTag != "" {
|
||||
spans = append(spans, tagSpan{
|
||||
open: openTag,
|
||||
close: closeTag,
|
||||
start: entity.Offset,
|
||||
end: entity.Offset + entity.Length,
|
||||
len: entity.Length,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
opens := make(map[int][]tagSpan)
|
||||
closes := make(map[int][]tagSpan)
|
||||
for _, span := range spans {
|
||||
opens[span.start] = append(opens[span.start], span)
|
||||
closes[span.end] = append(closes[span.end], span)
|
||||
}
|
||||
|
||||
// Функция для проверки, находится ли позиция в skipRanges
|
||||
isInSkipRange := func(pos int) bool {
|
||||
for skipRange := range skipRanges {
|
||||
if pos >= skipRange[0] && pos < skipRange[1] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Пишем текст один раз, вставляя HTML-теги по позициям
|
||||
// и экранируя HTML-спецсимволы
|
||||
var result strings.Builder
|
||||
for i := 0; i <= len(runes); i++ {
|
||||
if closing, ok := closes[i]; ok {
|
||||
sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len })
|
||||
for _, span := range closing {
|
||||
result.WriteString(span.close)
|
||||
}
|
||||
}
|
||||
if opening, ok := opens[i]; ok {
|
||||
sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len })
|
||||
for _, span := range opening {
|
||||
result.WriteString(span.open)
|
||||
}
|
||||
}
|
||||
if i < len(runes) {
|
||||
// Пропускаем символы, которые находятся в skipRanges (текст пригласительных ссылок)
|
||||
if isInSkipRange(i) {
|
||||
continue
|
||||
}
|
||||
// Экранируем HTML-спецсимволы
|
||||
switch runes[i] {
|
||||
case '<':
|
||||
result.WriteString("<")
|
||||
case '>':
|
||||
result.WriteString(">")
|
||||
case '&':
|
||||
result.WriteString("&")
|
||||
default:
|
||||
result.WriteRune(runes[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
101
telegram_bot/screens/creative_editor_ui.go
Normal file
101
telegram_bot/screens/creative_editor_ui.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"example.com/m/bot"
|
||||
"github.com/NicoNex/echotron/v3"
|
||||
)
|
||||
|
||||
const msgEditCreativeText = `
|
||||
<b>✎ Введите текст креатива:</b>
|
||||
|
||||
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
<b>Пример:</b>
|
||||
<i>Присоединяйтесь к нашему каналу!
|
||||
https://t.me/+AbCdEfGhIjKlMn</i>
|
||||
|
||||
`
|
||||
const msgAddButtonPrompt = `
|
||||
<b>+ Добавить кнопку</b>
|
||||
|
||||
Введите <b>текст кнопки:</b>
|
||||
|
||||
<i>Например:</i>
|
||||
• Перейти на сайт
|
||||
• Написать нам
|
||||
|
||||
<i>Ссылка на канал подставится автоматически при создании закупа.</i>
|
||||
|
||||
`
|
||||
const msgAddMediaPrompt = `
|
||||
<b>+ Добавить медиа</b>
|
||||
|
||||
Отправьте <b>фото, видео или GIF</b>
|
||||
|
||||
<i>Это медиа будет отображаться в креативе вместе с текстом</i>
|
||||
|
||||
`
|
||||
const msgButtonURLPrompt = `
|
||||
<b>✧ Введите URL для кнопки:</b>
|
||||
|
||||
URL должен начинаться с <code>http://</code> или <code>https://</code>
|
||||
|
||||
<i>Например:</i>
|
||||
<code>https://example.com</code>
|
||||
<code>https://t.me/yourchannel</code>
|
||||
|
||||
`
|
||||
const msgInvalidURL = `
|
||||
<b>❌ Неверный формат URL</b>
|
||||
|
||||
URL должен начинаться с <code>http://</code> или <code>https://</code>
|
||||
|
||||
Попробуйте ещё раз.
|
||||
|
||||
`
|
||||
|
||||
func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
|
||||
inviteLabel := "Ссылка на канал"
|
||||
customLabel := "Своя ссылка"
|
||||
if e.PendingButtonType == "invite" {
|
||||
inviteLabel = "✓ Ссылка на канал"
|
||||
}
|
||||
if e.PendingButtonType == "custom" {
|
||||
customLabel = "✓ Своя ссылка"
|
||||
}
|
||||
|
||||
e.ShowControlPanel(b, msgAddButtonPrompt, [][]echotron.InlineKeyboardButton{
|
||||
Row(
|
||||
Button(inviteLabel, "button_type_invite"),
|
||||
Button(customLabel, "button_type_custom"),
|
||||
),
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowTextEditPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgEditCreativeText, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_edit")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowMediaPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgAddMediaPrompt, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_media")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowButtonURLPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgButtonURLPrompt, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
}
|
||||
|
||||
func (e *CreativeEditorFields) ShowInvalidButtonURLPanel(b *bot.Bot) {
|
||||
e.ShowControlPanel(b, msgInvalidURL, [][]echotron.InlineKeyboardButton{
|
||||
Row(Button("« Отмена", "cancel_add_button")),
|
||||
})
|
||||
}
|
||||
@@ -150,11 +150,11 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
))
|
||||
|
||||
case data == "add_creative":
|
||||
b.SetState(&AddCreative{
|
||||
b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{
|
||||
ProjectID: s.ProjectID,
|
||||
WorkspaceID: s.WorkspaceID,
|
||||
BackState: s,
|
||||
}, bot.EditMessage)
|
||||
}}, bot.EditMessage)
|
||||
|
||||
case strings.HasPrefix(data, "creative:"):
|
||||
// Открытие деталей креатива
|
||||
@@ -179,3 +179,5 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Creatives) Exit() {}
|
||||
|
||||
@@ -38,3 +38,5 @@ func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {}
|
||||
func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *Login) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
|
||||
|
||||
func (s *Login) Exit() {}
|
||||
|
||||
@@ -49,3 +49,5 @@ func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {}
|
||||
|
||||
func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
|
||||
|
||||
func (s *MainMenu) Exit() {}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"example.com/m/bot"
|
||||
"example.com/m/ui"
|
||||
@@ -18,6 +19,11 @@ type MyProjects struct {
|
||||
WorkspaceID string
|
||||
BackState bot.State
|
||||
OpenPurchases bool
|
||||
|
||||
// Поля для управления фоновой горутиной polling
|
||||
cancel context.CancelFunc
|
||||
pollDone chan struct{}
|
||||
lastProjectCount int
|
||||
}
|
||||
|
||||
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
@@ -70,6 +76,9 @@ func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||
}
|
||||
|
||||
s.renderProjects(b, mode, jwt)
|
||||
|
||||
// Запускаем фоновую горутину для автообновления экрана
|
||||
s.startPolling(b, jwt)
|
||||
}
|
||||
|
||||
func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) {
|
||||
@@ -80,6 +89,9 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
|
||||
return
|
||||
}
|
||||
|
||||
// Сохраняем количество проектов для polling
|
||||
s.lastProjectCount = page.Total
|
||||
|
||||
var text string
|
||||
var buttons [][]echotron.InlineKeyboardButton
|
||||
headerTitle := "Мои проекты"
|
||||
@@ -245,3 +257,100 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *MyProjects) Exit() {
|
||||
// Останавливаем polling горутину
|
||||
if s.cancel != nil {
|
||||
log.Info().Msg("MyProjects: cancelling polling goroutine")
|
||||
s.cancel()
|
||||
}
|
||||
|
||||
// Ждем завершения горутины с таймаутом 100ms
|
||||
// Это не критично - горутина сама завершится при следующем tick
|
||||
if s.pollDone != nil {
|
||||
select {
|
||||
case <-s.pollDone:
|
||||
log.Info().Msg("MyProjects: polling goroutine finished")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
log.Info().Msg("MyProjects: polling cleanup timeout, goroutine will finish in background")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MyProjects) startPolling(b *bot.Bot, jwt string) {
|
||||
// Останавливаем предыдущую горутину если она есть
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
if s.pollDone != nil {
|
||||
<-s.pollDone
|
||||
}
|
||||
}
|
||||
|
||||
// Создаем новый контекст для горутины
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.pollDone = make(chan struct{})
|
||||
|
||||
go s.pollProjects(ctx, b, jwt)
|
||||
}
|
||||
|
||||
func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot, jwt string) {
|
||||
defer close(s.pollDone)
|
||||
|
||||
// Адаптивный интервал: первые 30 секунд проверяем часто (1 сек),
|
||||
// потом переходим на более редкий polling (3 сек)
|
||||
// Это улучшает UX когда пользователь только добавил проект
|
||||
intervals := []struct {
|
||||
duration time.Duration
|
||||
count int
|
||||
}{
|
||||
{1 * time.Second, 30}, // Первые 30 секунд - каждую секунду
|
||||
{3 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно)
|
||||
}
|
||||
|
||||
currentIntervalIdx := 0
|
||||
tickCount := 0
|
||||
ticker := time.NewTicker(intervals[0].duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Info().Msg("MyProjects polling: context cancelled, stopping")
|
||||
return
|
||||
|
||||
case <-ticker.C:
|
||||
tickCount++
|
||||
|
||||
// Проверяем, нужно ли переключить интервал
|
||||
if currentIntervalIdx < len(intervals)-1 {
|
||||
if intervals[currentIntervalIdx].count > 0 && tickCount >= intervals[currentIntervalIdx].count {
|
||||
currentIntervalIdx++
|
||||
tickCount = 0
|
||||
ticker.Reset(intervals[currentIntervalIdx].duration)
|
||||
log.Info().
|
||||
Dur("new_interval", intervals[currentIntervalIdx].duration).
|
||||
Msg("MyProjects polling: switching to slower interval")
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем количество проектов
|
||||
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("MyProjects polling: failed to get projects")
|
||||
continue
|
||||
}
|
||||
|
||||
// Если количество изменилось - перерендериваем
|
||||
if page.Total != s.lastProjectCount {
|
||||
log.Info().
|
||||
Int("old_count", s.lastProjectCount).
|
||||
Int("new_count", page.Total).
|
||||
Msg("MyProjects polling: project count changed, re-rendering")
|
||||
|
||||
s.lastProjectCount = page.Total
|
||||
s.renderProjects(b, bot.EditMessage, jwt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,3 +202,5 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *ProjectDetails) Exit() {}
|
||||
|
||||
@@ -269,3 +269,5 @@ func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PurchaseDetails) Exit() {}
|
||||
|
||||
@@ -347,6 +347,8 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update)
|
||||
|
||||
func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *PurchaseOptionalDetails) Exit() {}
|
||||
|
||||
func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) {
|
||||
switch key {
|
||||
case "placement_datetime":
|
||||
|
||||
@@ -145,3 +145,5 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *Purchases) Exit() {}
|
||||
|
||||
@@ -375,3 +375,5 @@ func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SelectChannelsForPurchase) Exit() {}
|
||||
|
||||
@@ -147,3 +147,5 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
func (s *SelectWorkspace) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return }
|
||||
|
||||
func (s *SelectWorkspace) Exit() {}
|
||||
|
||||
Reference in New Issue
Block a user