This commit is contained in:
Artem Tsyrulnikov
2026-01-15 22:02:16 +03:00
parent 6ec26c96e3
commit 049024ebe8
72 changed files with 2417 additions and 2054 deletions

View File

@@ -103,6 +103,11 @@ func (b *Bot) SetState(s State, mode RenderMode) {
Str("mode", fmt.Sprintf("%v", mode)).
Msg("State transition")
// Вызываем Exit() у старого состояния для cleanup (например, отмены горутин)
if b.CurrentState != nil {
b.CurrentState.Exit()
}
b.CurrentState = s
if b.exec != nil {

View File

@@ -14,6 +14,7 @@ type State interface {
HandleCallback(*Bot, *echotron.Update)
HandleMessage(*Bot, *echotron.Update)
Handle(*Bot, *echotron.Update)
Exit() // Вызывается при выходе из состояния для cleanup (например, отмены горутин)
}
type Session struct {

View File

@@ -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() {}

View File

@@ -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, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
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)
}

View File

@@ -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() {}

View File

@@ -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() {}

View File

@@ -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

View File

@@ -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("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}
return result.String()
}

View 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")),
})
}

View File

@@ -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() {}

View File

@@ -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() {}

View File

@@ -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() {}

View File

@@ -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)
}
}
}
}

View File

@@ -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() {}

View File

@@ -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() {}

View File

@@ -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":

View File

@@ -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() {}

View File

@@ -375,3 +375,5 @@ func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
}
}
}
func (s *SelectChannelsForPurchase) Exit() {}

View File

@@ -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() {}

View File

@@ -601,6 +601,8 @@ func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) Exit() {}
func parseDateTimeInput(
input string,
currentDate *time.Time,

View File

@@ -0,0 +1,304 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/NicoNex/echotron/v3"
)
type htmlTag struct {
open string
close string
}
var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{
echotron.BoldEntity: {open: "<b>", close: "</b>"},
echotron.ItalicEntity: {open: "<i>", close: "</i>"},
echotron.UnderlineEntity: {open: "<u>", close: "</u>"},
echotron.StrikethroughEntity: {open: "<s>", close: "</s>"},
echotron.CodeEntity: {open: "<code>", close: "</code>"},
echotron.PreEntity: {open: "<pre>", close: "</pre>"},
}
// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
// Invite links are normalized into <a class="tg-link"> placeholders for previews.
func FormatMessageHTML(message *echotron.Message) string {
if message == nil {
return ""
}
// Telegram sends formatting as entities, so we rebuild the HTML from offsets.
text := message.Text
entities := message.Entities
if text == "" {
text = message.Caption
entities = message.CaptionEntities
}
if text == "" {
return ""
}
if len(entities) == 0 {
return EscapeHTML(text)
}
type tagSpan struct {
open string
close string
start int
end int
len int
}
runes := []rune(text)
positions := make([]int, len(runes)+1)
utf16Count := 0
for i, r := range runes {
positions[i] = utf16Count
if r > 0xFFFF {
utf16Count += 2
} else {
utf16Count++
}
}
positions[len(runes)] = utf16Count
utf16ToRuneIndex := func(utf16Index int) (int, bool) {
i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index })
if i < len(positions) && positions[i] == utf16Index {
return i, true
}
return 0, false
}
toRuneRange := func(offset, length int) (int, int, bool) {
start, ok := utf16ToRuneIndex(offset)
if !ok {
return 0, 0, false
}
end, ok := utf16ToRuneIndex(offset + length)
if !ok || end > len(runes) || end < start {
return 0, 0, false
}
return start, end, true
}
// URL ranges are used to prevent nested tags inside links.
urlRanges := make([][2]int, 0, len(entities))
for _, entity := range entities {
if entity == nil {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
urlRanges = append(urlRanges, [2]int{start, end})
if entity.Type == echotron.UrlEntity {
_ = string(runes[start:end])
}
}
isInsideURL := func(offset, length int) bool {
for _, urlRange := range urlRanges {
if offset > urlRange[0] && offset+length <= urlRange[1] {
return true
}
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 {
if entity == nil {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) {
continue
}
var openTag, closeTag string
if tag, ok := simpleHTMLTags[entity.Type]; ok {
openTag, closeTag = tag.open, tag.close
} else if entity.Type == echotron.UrlEntity {
entityText := string(runes[start:end])
if isInviteLink(entityText) {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
closeTag = "</a>"
}
} else if entity.Type == echotron.TextLinkEntity {
if entity.URL != "" {
if isInviteLink(entity.URL) {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
closeTag = "</a>"
}
}
}
if openTag != "" && closeTag != "" {
spans = append(spans, tagSpan{
open: openTag,
close: closeTag,
start: start,
end: end,
len: end - start,
})
}
}
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)
}
// Build the final text with tags inserted and HTML escaped.
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) {
switch runes[i] {
case '<':
result.WriteString("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}
return normalizeHTMLTags(result.String())
}
// isInviteLink detects Telegram invite links used in creatives.
func isInviteLink(url string) bool {
return strings.Contains(url, "t.me/+") || strings.Contains(url, "t.me/joinchat/")
}
func normalizeHTMLTags(input string) string {
if input == "" {
return input
}
allowed := map[string]bool{
"a": true,
"b": true,
"i": true,
"u": true,
"s": true,
"code": true,
"pre": true,
}
var out strings.Builder
out.Grow(len(input))
stack := make([]string, 0, 8)
for i := 0; i < len(input); {
if input[i] != '<' {
out.WriteByte(input[i])
i++
continue
}
end := strings.IndexByte(input[i:], '>')
if end == -1 {
out.WriteByte(input[i])
i++
continue
}
end += i
tag := input[i+1 : end]
if tag == "" {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
isClosing := tag[0] == '/'
tagName := tag
if isClosing {
tagName = tag[1:]
}
if space := strings.IndexByte(tagName, ' '); space != -1 {
tagName = tagName[:space]
}
tagName = strings.TrimSpace(tagName)
if !allowed[tagName] {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
if isClosing {
if len(stack) > 0 && stack[len(stack)-1] == tagName {
stack = stack[:len(stack)-1]
out.WriteString(input[i : end+1])
}
} else {
stack = append(stack, tagName)
out.WriteString(input[i : end+1])
}
i = end + 1
}
for i := len(stack) - 1; i >= 0; i-- {
out.WriteString("</")
out.WriteString(stack[i])
out.WriteString(">")
}
return out.String()
}
// EscapeHTML escapes HTML special characters for safe output.
func EscapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
func SanitizeHTML(input string) string {
return normalizeHTMLTags(input)
}