создание креатива постом

This commit is contained in:
Artem Tsyrulnikov
2026-01-06 17:47:31 +03:00
parent 52e65dff05
commit 61f7ed093c
6 changed files with 366 additions and 93 deletions

View File

@@ -13,8 +13,7 @@ import (
)
const (
botToken = "8134938541:AAFcZhIGfYynQ9axyuA35R1nd7sPQZodrrQ"
botUsername = "tgex_bot" // TODO: заменить на реальный username бота
botToken = "7718883894:AAHUqtjqHtSs2Rp6Tb4PokqU6syfKmcFvOo"
)
func main() {

View File

@@ -14,9 +14,12 @@ import (
type AddCreative struct {
CreativeEditorFields // Встраивание общих полей
ProjectID string
WorkspaceID string
BackState bot.State
ProjectID string
WorkspaceID string
BackState bot.State
WaitingForCreative bool // Ждем пересылки сообщения с креативом
CreativeMessageSent bool // Креатив уже получен и показан
WaitingForTextEdit bool // Ждем редактирования текста креатива
}
func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) {
@@ -24,18 +27,18 @@ func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) {
<b> Добавить креатив</b>
Введите <b>название</b> креатива:
📄 <b>Пришлите креатив который хотите добавить</b>
<i>Например:</i>
‣ Летняя акция
‣ Баннер 1920x1080
‣ Видео промо`
• Обрабатываем любое сообщение Telegram
• Отправляйте сразу оформленный пост
На следующем шаге можно добавить кнопки или изменить контент`
buttons := [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel")),
}
keyboard := bot.Keyboard(buttons...)
s.WaitingForCreative = true
b.Render(text, keyboard, mode)
}
@@ -53,7 +56,8 @@ func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) {
}
case data == "edit_text":
// Редактируем панель управления - просим текст
// Начинаем редактирование текста
s.WaitingForTextEdit = true
s.ShowControlPanel(b, `<b>✎ Введите текст креатива:</b>
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
@@ -99,7 +103,12 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
s.MediaFileID = ""
s.MediaChanged = true
s.UpdateCreativePreview(b)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
if s.CreativeMessageSent {
s.showCreativeConfirmation(b)
} else {
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
case data == "cancel_add_button":
// Отменяем добавление кнопки
@@ -114,12 +123,30 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
s.WaitingForButtonText = false
s.WaitingForButtonURL = false
s.CurrentButtonText = nil
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
case data == "cancel_edit", data == "cancel_media":
// Отменяем редактирование текста или добавление медиа
if s.CreativeMessageSent {
s.showCreativeConfirmation(b)
} else {
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
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
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
if s.CreativeMessageSent {
s.showCreativeConfirmation(b)
} else {
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
case data == "confirm_create":
// Создаём креатив
@@ -134,7 +161,12 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
if index >= 0 && index < len(s.Buttons) {
s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...)
s.UpdateCreativePreview(b)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
if s.CreativeMessageSent {
s.showCreativeConfirmation(b)
} else {
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
}
}
}
@@ -149,25 +181,36 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
return
}
// Шаг 1: Получаем название
if s.Name == nil {
if u.Message.Text == "" {
return
}
name := u.Message.Text
s.Name = &name
// Шаг 1: Ждем пересылки креатива (любое сообщение)
if s.WaitingForCreative {
// Извлекаем данные из сообщения
s.extractCreativeFromMessage(u.Message)
// Отправляем креатив + панель управления
s.sendCreativeAndPanel(b)
// Генерируем название из текста или дефолтное
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
@@ -186,7 +229,6 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
}
if handled {
// Удаляем сообщение пользователя
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
@@ -194,39 +236,40 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
s.WaitingForMedia = false
s.UpdateCreativePreview(b)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
if s.CreativeMessageSent {
s.showCreativeConfirmation(b)
} else {
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
return
}
}
// Если не медиа и не текст - игнорируем
if u.Message.Text == "" {
return
}
// Удаляем сообщение пользователя
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
// Обработка добавления кнопки - текст кнопки
if s.WaitingForButtonText {
if u.Message.Text == "" {
return
}
buttonText := u.Message.Text
s.CurrentButtonText = &buttonText
s.WaitingForButtonText = false
s.WaitingForButtonURL = true
// Сразу создаём кнопку с placeholder URL (используем валидный URL)
// Удаляем сообщение пользователя
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
s.Buttons = append(s.Buttons, InlineButton{
Text: buttonText,
URL: "https://example.com",
})
// Обновляем превью креатива, чтобы показать новую кнопку
s.UpdateCreativePreview(b)
// Редактируем панель управления - просим URL
s.ShowControlPanel(b, `<b>✧ Введите URL для кнопки:</b>
URL должен начинаться с <code>https://</code>
@@ -241,9 +284,18 @@ URL должен начинаться с <code>https://</code>
// Обработка добавления кнопки - URL
if s.WaitingForButtonURL {
if u.Message.Text == "" {
return
}
url := u.Message.Text
// Базовая валидация URL
// Удаляем сообщение пользователя
_, 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>
@@ -255,45 +307,212 @@ URL должен начинаться с <code>https://</code>
return
}
// Обновляем URL последней кнопки (которую создали с placeholder)
if len(s.Buttons) > 0 {
s.Buttons[len(s.Buttons)-1].URL = url
}
s.WaitingForButtonURL = false
s.CurrentButtonText = nil
// Обновляем креатив и возвращаем панель управления
s.UpdateCreativePreview(b)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
if s.CreativeMessageSent {
s.showCreativeConfirmation(b)
} else {
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
return
}
// Шаг 2: Получаем текст
if s.Text == nil {
// Если ждем редактирования текста креатива
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)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
if s.CreativeMessageSent {
s.showCreativeConfirmation(b)
} else {
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
return
}
// Если текст уже задан - это повторное редактирование
text := s.GetFormattedText(u.Message)
s.Text = &text
// Если креатив уже показан и пользователь вводит текст - это изменение названия
if s.CreativeMessageSent && u.Message.Text != "" {
newName := u.Message.Text
// Обновляем креатив и возвращаем панель управления
s.UpdateCreativePreview(b)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
// Ограничение 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
}
}
func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return }
// extractCreativeFromMessage извлекает данные креатива из сообщения
func (s *AddCreative) extractCreativeFromMessage(message *echotron.Message) {
// Извлекаем текст с форматированием (GetFormattedText теперь поддерживает и text и caption)
if message.Text != "" || message.Caption != "" {
text := s.GetFormattedText(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
}
// Извлекаем кнопки из 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,
URL: button.URL,
})
}
}
}
}
}
// generateCreativeName генерирует название из текста или дефолтное
func (s *AddCreative) generateCreativeName() string {
// Если есть текст, берем первые слова
if s.Text != nil && *s.Text != "" {
// Удаляем HTML теги для генерации названия
cleanText := strings.ReplaceAll(*s.Text, "<b>", "")
cleanText = strings.ReplaceAll(cleanText, "</b>", "")
cleanText = strings.ReplaceAll(cleanText, "<i>", "")
cleanText = strings.ReplaceAll(cleanText, "</i>", "")
cleanText = strings.ReplaceAll(cleanText, "<u>", "")
cleanText = strings.ReplaceAll(cleanText, "</u>", "")
cleanText = strings.ReplaceAll(cleanText, "<s>", "")
cleanText = strings.ReplaceAll(cleanText, "</s>", "")
cleanText = strings.ReplaceAll(cleanText, "<code>", "")
cleanText = strings.ReplaceAll(cleanText, "</code>", "")
// Берем первые 50 символов
runes := []rune(strings.TrimSpace(cleanText))
if len(runes) > 50 {
return string(runes[:50]) + "..."
}
if len(runes) > 0 {
return string(runes)
}
}
// Дефолтное название
return "Новый креатив"
}
// showCreativeConfirmation показывает превью креатива + панель с предложением добавить
func (s *AddCreative) showCreativeConfirmation(b *bot.Bot) {
// 1. Отправляем/обновляем превью креатива
if s.CreativeMessageID == nil {
s.SendCreativePreview(b)
} else {
s.UpdateCreativePreview(b)
}
// 2. Формируем текст панели управления
confirmText := fmt.Sprintf(`<b> Добавляем этот креатив?</b>
<b>Название креатива:</b> %s
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>`, *s.Name)
// 3. Показываем панель с кнопками редактирования + сохранения
var buttons [][]echotron.InlineKeyboardButton
// Кнопки редактирования
buttons = append(buttons, bot.Row(
bot.Button("✎ Текст", "edit_text"),
bot.Button(" Медиа", "add_media"),
))
buttons = append(buttons, bot.Row(bot.Button(" Добавить кнопку", "add_button")))
// Кнопка удаления медиа (если медиа добавлено)
if s.MediaFileID != "" {
buttons = append(buttons, bot.Row(
bot.Button("⌫ Убрать медиа", "delete_media"),
))
}
// Кнопки удаления добавленных кнопок
if len(s.Buttons) > 0 {
var row []echotron.InlineKeyboardButton
for i, btn := range s.Buttons {
row = append(row, bot.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, bot.Row(
bot.Button("← Отмена", "cancel"),
bot.Button("✓ Сохранить", "confirm_create"),
))
} else {
buttons = append(buttons, bot.Row(
bot.Button("← Отмена", "cancel"),
))
}
s.ShowControlPanel(b, confirmText, buttons)
}
// sendCreativeAndPanel отправляет превью креатива и панель управления
func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) {
// Устанавливаем навигацию
s.Navigation = "… Креативы Добавление"
// 1. Отправляем превью креатива
s.SendCreativePreview(b)
@@ -331,6 +550,15 @@ 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(),

View File

@@ -58,9 +58,6 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
}
s.MediaChanged = false
// Устанавливаем навигацию
s.Navigation = "… Креативы Детали"
log.Info().
Str("creative_id", s.CreativeID).
Str("name", creative.Name).
@@ -195,6 +192,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
// Обрабатываем добавление медиа
if s.WaitingForMedia {
handled := false
if u.Message.Photo != nil && len(u.Message.Photo) > 0 {
photos := u.Message.Photo
largestPhoto := photos[len(photos)-1]
@@ -202,26 +200,29 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
s.MediaFileID = largestPhoto.FileID
s.MediaChanged = true
s.WaitingForMedia = false
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
return
handled = true
} else if u.Message.Video != nil {
s.MediaType = "video"
s.MediaFileID = u.Message.Video.FileID
s.MediaChanged = true
s.WaitingForMedia = false
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
return
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)
@@ -236,6 +237,12 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
s.WaitingForButtonText = false
s.WaitingForButtonURL = true
// Удаляем сообщение пользователя
_, err := b.DeleteMessage(b.ChatID, u.Message.ID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
// Создаём кнопку с placeholder URL
s.Buttons = append(s.Buttons, InlineButton{
Text: buttonText,
@@ -257,6 +264,12 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
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")
}
// Простая валидация URL
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
s.ShowControlPanel(b, `<b>❌ Неверный формат URL</b>
@@ -287,6 +300,12 @@ URL должен начинаться с <code>http://</code> или <code>https
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.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
return
@@ -297,6 +316,12 @@ URL должен начинаться с <code>http://</code> или <code>https
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.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
}

View File

@@ -28,9 +28,6 @@ type CreativeEditorFields struct {
MediaFileID string // File ID из Telegram
MediaChanged bool
// Навигация (хлебные крошки)
Navigation string
// Состояние для добавления кнопки
WaitingForButtonText bool
WaitingForButtonURL bool
@@ -50,11 +47,6 @@ type CreativeEditorFields struct {
func (e *CreativeEditorFields) GetCreativeText() string {
var result string
// Добавляем навигацию если она есть
if e.Navigation != "" {
result = fmt.Sprintf("<i>%s</i>\n\n", e.Navigation)
}
if e.Text != nil && *e.Text != "" {
// Проверяем что текст не является "визуально пустым" HTML
// Например: "<a class=\"tg-link\"></a>" - пустая ссылка
@@ -364,13 +356,25 @@ func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons
// GetFormattedText извлекает форматированный текст из сообщения с entities
func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) string {
if message.Text == "" {
// Определяем откуда брать текст и 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(message.Entities) == 0 {
return message.Text
if len(entities) == 0 {
return text
}
type tagSpan struct {
@@ -382,10 +386,10 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
}
// Преобразуем текст в руны для правильной работы с UTF-8
runes := []rune(message.Text)
runes := []rune(text)
var spans []tagSpan
for _, entity := range message.Entities {
for _, entity := range entities {
// URL уже присутствует в сыром тексте, чтобы не дублировать - пропускаем entity.
if entity.Type == "url" {
continue
@@ -438,6 +442,7 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
}
// Пишем текст один раз, вставляя HTML-теги по позициям
// и экранируя HTML-спецсимволы
var result strings.Builder
for i := 0; i <= len(runes); i++ {
if closing, ok := closes[i]; ok {
@@ -453,7 +458,17 @@ func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) strin
}
}
if i < len(runes) {
result.WriteRune(runes[i])
// Экранируем HTML-спецсимволы
switch runes[i] {
case '<':
result.WriteString("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}