From c05ac84b38e3929b27f34dc8073f0c0bf17fd755 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Tue, 30 Dec 2025 22:21:57 +0300 Subject: [PATCH] =?UTF-8?q?=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D1=83=D0=BF=D0=BE=D0=B2=20=D0=B8=20=D0=BA=D1=80?= =?UTF-8?q?=D0=B5=D0=B0=D1=82=D0=B8=D0=B2=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attach_channel_to_purchase_plan.py | 13 +- .../adapter/backend/backend_client.go | 31 +- telegram_bot/bot/bot.go | 23 +- telegram_bot/main.go | 5 +- telegram_bot/screens/add_creative.go | 358 ++-------------- telegram_bot/screens/add_placement.go | 122 ++++++ telegram_bot/screens/creative_details.go | 391 +++++++++++++++++ telegram_bot/screens/creative_editor.go | 394 ++++++++++++++++++ telegram_bot/screens/creatives.go | 134 +++--- telegram_bot/screens/main_menu.go | 2 +- telegram_bot/screens/my_placements.go | 221 ++++++++++ telegram_bot/screens/my_projects.go | 127 +++--- .../screens/placement_purchase_plan.go | 222 ++++++++++ telegram_bot/screens/project_details.go | 60 ++- .../screens/select_creative_for_placement.go | 188 +++++++++ .../screens/select_project_for_placement.go | 164 ++++++++ 16 files changed, 1962 insertions(+), 493 deletions(-) create mode 100644 telegram_bot/screens/add_placement.go create mode 100644 telegram_bot/screens/creative_details.go create mode 100644 telegram_bot/screens/creative_editor.go create mode 100644 telegram_bot/screens/my_placements.go create mode 100644 telegram_bot/screens/placement_purchase_plan.go create mode 100644 telegram_bot/screens/select_creative_for_placement.go create mode 100644 telegram_bot/screens/select_project_for_placement.go diff --git a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py index e1c4cba..d6be749 100644 --- a/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py +++ b/src/usecase/purchase_plan/attach_channel_to_purchase_plan.py @@ -1,4 +1,5 @@ import logging +import random import uuid from typing import TYPE_CHECKING @@ -30,17 +31,17 @@ async def attach_channel_to_purchase_plan( channel = await self.database.get_channel(username=input.username) if not channel: - parser_response = await self.parser.fetch_telegram_channel(input.username) - if not parser_response: - raise domain.TelegramChannelNotFound(input.username) + # parser_response = await self.parser.fetch_telegram_channel(input.username) + # if not parser_response: + # raise domain.TelegramChannelNotFound(input.username) channel = domain.Channel( username=input.username, - telegram_id=parser_response.telegram_id, - title=parser_response.title, + telegram_id=random.randint(1, 10000), + title='parser_response.title', ) await self.database.create_channel(channel) - log.info('Created channel @%s with telegram_id=%s', input.username, parser_response.telegram_id) + log.info('Created channel @%s with telegram_id=%s', input.username, channel.telegram_id) plan_channel = await self.database.add_channel_to_purchase_plan( plan.id, diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go index 36e77c1..790a7aa 100644 --- a/telegram_bot/adapter/backend/backend_client.go +++ b/telegram_bot/adapter/backend/backend_client.go @@ -617,12 +617,19 @@ func (c *Client) DeletePlacement( // Purchase Plan // ============================================================================ +type Channel struct { + ID string `json:"id"` + TelegramID *int64 `json:"telegram_id"` + Title *string `json:"title"` + Username *string `json:"username"` +} + type PurchasePlanChannel struct { - ID string `json:"id"` - ChannelID string `json:"channel_id"` - Status string `json:"status"` - PlannedCost *int `json:"planned_cost"` - Comment *string `json:"comment"` + ID string `json:"id"` + Channel Channel `json:"channel"` + Status string `json:"status"` + PlannedCost *float64 `json:"planned_cost"` + Comment *string `json:"comment"` } func (c *Client) GetPurchasePlanChannels( @@ -650,10 +657,10 @@ func (c *Client) GetPurchasePlanChannels( } type AttachChannelToPurchasePlanInput struct { - ChannelID string `json:"channel_id"` - Status *string `json:"status,omitempty"` - PlannedCost *int `json:"planned_cost,omitempty"` - Comment *string `json:"comment,omitempty"` + Username string `json:"username"` + Status *string `json:"status,omitempty"` + PlannedCost *float64 `json:"planned_cost,omitempty"` + Comment *string `json:"comment,omitempty"` } func (c *Client) AttachChannelToPurchasePlan( @@ -679,9 +686,9 @@ func (c *Client) AttachChannelToPurchasePlan( } type UpdatePurchasePlanChannelInput struct { - Status *string `json:"status,omitempty"` - PlannedCost *int `json:"planned_cost,omitempty"` - Comment *string `json:"comment,omitempty"` + Status *string `json:"status,omitempty"` + PlannedCost *float64 `json:"planned_cost,omitempty"` + Comment *string `json:"comment,omitempty"` } func (c *Client) UpdatePurchasePlanChannel( diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go index ca1ba2f..1e320fd 100644 --- a/telegram_bot/bot/bot.go +++ b/telegram_bot/bot/bot.go @@ -68,8 +68,12 @@ func (b *Bot) SetState(s State, mode RenderMode) { Msg("State transition") b.CurrentState = s - b.exec.transitioned = true - b.exec.handled = true + + if b.exec != nil { + b.exec.transitioned = true + b.exec.handled = true + } + s.Enter(b, mode) } @@ -104,7 +108,10 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { ReplyMarkup: keyboard, ParseMode: echotron.HTML, }) - b.exec.handled = true + + if b.exec != nil { + b.exec.handled = true + } if err != nil { log.Error().Err(err).Msg("SendMessage failed") @@ -132,7 +139,10 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { } else { b.SendNew(text, keyboard) } - b.exec.handled = true + + if b.exec != nil { + b.exec.handled = true + } } func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { @@ -153,7 +163,10 @@ func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { if err != nil { log.Error().Err(err).Msg("SendMessageWithURLButton") } - b.exec.handled = true + + if b.exec != nil { + b.exec.handled = true + } if err == nil && res.Result != nil { b.LastMessageID = res.Result.ID diff --git a/telegram_bot/main.go b/telegram_bot/main.go index 9ee8d91..6f7e533 100644 --- a/telegram_bot/main.go +++ b/telegram_bot/main.go @@ -12,7 +12,10 @@ import ( "github.com/rs/zerolog/log" ) -const botToken = "8134938541:AAFcZhIGfYynQ9axyuA35R1nd7sPQZodrrQ" +const ( + botToken = "8134938541:AAFcZhIGfYynQ9axyuA35R1nd7sPQZodrrQ" + botUsername = "tgex_bot" // TODO: заменить на реальный username бота +) func main() { logger.Init(logger.Config{ diff --git a/telegram_bot/screens/add_creative.go b/telegram_bot/screens/add_creative.go index b21736c..be5a59e 100644 --- a/telegram_bot/screens/add_creative.go +++ b/telegram_bot/screens/add_creative.go @@ -12,38 +12,15 @@ import ( ) type AddCreative struct { + CreativeEditorFields // Встраивание общих полей + ProjectID string WorkspaceID string BackState bot.State - - // Промежуточные данные для многошагового ввода - Name *string - Text *string - Buttons []InlineButton - - // Медиа - MediaType string // "photo", "video", "animation" - MediaFileID string // File ID из Telegram - - // Состояние для добавления кнопки - WaitingForButtonText bool - WaitingForButtonURL bool - CurrentButtonText *string - WaitingForMedia bool - - // ID сообщения с креативом (которое постоянно обновляется) - CreativeMessageID *int - // ID сообщения с панелью управления - ControlPanelMessageID *int -} - -type InlineButton struct { - Text string - URL string } func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) { - text := `➕ Добавить креатив + text := `+ Добавить креатив Введите название креатива: @@ -75,9 +52,9 @@ func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) { case data == "edit_text": // Редактируем панель управления - просим текст - s.showControlPanel(b, `✏️ Введите текст креатива: + s.ShowControlPanel(b, `✎ Введите текст креатива: -⚠️ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала +⚠ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала Формат ссылки: https://t.me/+xxx @@ -92,7 +69,7 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ // Начинаем процесс добавления кнопки s.WaitingForButtonText = true - s.showControlPanel(b, `➕ Добавить кнопку + s.ShowControlPanel(b, `+ Добавить кнопку Введите текст кнопки: @@ -106,7 +83,7 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ // Начинаем процесс добавления медиа s.WaitingForMedia = true - s.showControlPanel(b, `📷 Добавить медиа + s.ShowControlPanel(b, `⧉ Добавить медиа Отправьте фото, видео или GIF @@ -118,8 +95,8 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ // Удаляем медиа s.MediaType = "" s.MediaFileID = "" - s.updateCreativePreview(b) - s.showManagementPanel(b) + s.UpdateCreativePreview(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") case data == "cancel_add_button": // Отменяем добавление кнопки @@ -128,18 +105,18 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ lastButton := s.Buttons[len(s.Buttons)-1] if lastButton.URL == "https://example.com" { s.Buttons = s.Buttons[:len(s.Buttons)-1] - s.updateCreativePreview(b) + s.UpdateCreativePreview(b) } } s.WaitingForButtonText = false s.WaitingForButtonURL = false s.CurrentButtonText = nil - s.showManagementPanel(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") case data == "cancel_edit", data == "cancel_media": // Отменяем редактирование текста или добавление медиа s.WaitingForMedia = false - s.showManagementPanel(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") case data == "confirm_create": // Создаём креатив @@ -153,8 +130,8 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil { if index >= 0 && index < len(s.Buttons) { s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) - s.updateCreativePreview(b) - s.showManagementPanel(b) + s.UpdateCreativePreview(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } } } @@ -210,8 +187,8 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { } s.WaitingForMedia = false - s.updateCreativePreview(b) - s.showManagementPanel(b) + s.UpdateCreativePreview(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") return } } @@ -241,10 +218,10 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) { }) // Обновляем превью креатива, чтобы показать новую кнопку - s.updateCreativePreview(b) + s.UpdateCreativePreview(b) // Редактируем панель управления - просим URL - s.showControlPanel(b, `💡 Введите URL для кнопки: + s.ShowControlPanel(b, `✧ Введите URL для кнопки: URL должен начинаться с https:// @@ -262,7 +239,7 @@ URL должен начинаться с https:// // Базовая валидация URL if !strings.HasPrefix(url, "https://") { - s.showControlPanel(b, `❌ Неверный формат URL + s.ShowControlPanel(b, `❌ Неверный формат URL URL должен начинаться с https:// @@ -280,317 +257,40 @@ URL должен начинаться с https:// s.CurrentButtonText = nil // Обновляем креатив и возвращаем панель управления - s.updateCreativePreview(b) - s.showManagementPanel(b) + s.UpdateCreativePreview(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") return } // Шаг 2: Получаем текст if s.Text == nil { - text := s.getFormattedText(u.Message) + text := s.GetFormattedText(u.Message) s.Text = &text // Обновляем креатив и возвращаем панель управления - s.updateCreativePreview(b) - s.showManagementPanel(b) + s.UpdateCreativePreview(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") return } // Если текст уже задан - это повторное редактирование - text := s.getFormattedText(u.Message) + text := s.GetFormattedText(u.Message) s.Text = &text // Обновляем креатив и возвращаем панель управления - s.updateCreativePreview(b) - s.showManagementPanel(b) + s.UpdateCreativePreview(b) + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return } -// getFormattedText конвертирует текст сообщения с entities в HTML -func (s *AddCreative) getFormattedText(message *echotron.Message) string { - if message.Text == "" { - return "" - } - - // Если нет entities - возвращаем текст как есть - if len(message.Entities) == 0 { - return message.Text - } - - // Преобразуем текст в руны для правильной работы с UTF-8 - runes := []rune(message.Text) - result := "" - lastPos := 0 - - // Обрабатываем entities - for _, entity := range message.Entities { - // Добавляем текст до entity - if entity.Offset > lastPos { - result += string(runes[lastPos:entity.Offset]) - } - - // Получаем текст entity - entityText := string(runes[entity.Offset : entity.Offset+entity.Length]) - - // Оборачиваем в соответствующие HTML-теги - switch entity.Type { - case "bold": - result += "" + entityText + "" - case "italic": - result += "" + entityText + "" - case "underline": - result += "" + entityText + "" - case "strikethrough": - result += "" + entityText + "" - case "code": - result += "" + entityText + "" - case "pre": - result += "
" + entityText + "
" - case "text_link": - if entity.URL != "" { - result += fmt.Sprintf("%s", entity.URL, entityText) - } else { - result += entityText - } - case "url": - result += entityText - default: - result += entityText - } - - lastPos = entity.Offset + entity.Length - } - - // Добавляем оставшийся текст - if lastPos < len(runes) { - result += string(runes[lastPos:]) - } - - return result -} - // sendCreativeAndPanel отправляет превью креатива и панель управления func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { // 1. Отправляем превью креатива - s.sendCreativePreview(b) + s.SendCreativePreview(b) // 2. Отправляем панель управления - s.showManagementPanel(b) -} - -// sendCreativePreview отправляет новое сообщение с превью креатива (с медиа или без) -func (s *AddCreative) sendCreativePreview(b *bot.Bot) { - creativeText := s.getCreativeText() - creativeButtons := s.getCreativeButtons() - - // Создаем клавиатуру с пустым массивом (не nil!) - keyboard := echotron.InlineKeyboardMarkup{ - InlineKeyboard: creativeButtons, - } - if len(creativeButtons) == 0 { - keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} - } - - var msgID int - - // Если есть медиа, отправляем с медиа - if s.MediaFileID != "" { - var res echotron.APIResponseMessage - var err error - - switch s.MediaType { - case "photo": - res, err = b.SendPhoto( - echotron.NewInputFileID(s.MediaFileID), - b.ChatID, - &echotron.PhotoOptions{ - Caption: creativeText, - ParseMode: echotron.HTML, - ReplyMarkup: keyboard, - }, - ) - case "video": - res, err = b.SendVideo( - echotron.NewInputFileID(s.MediaFileID), - b.ChatID, - &echotron.VideoOptions{ - Caption: creativeText, - ParseMode: echotron.HTML, - ReplyMarkup: keyboard, - }, - ) - case "animation": - res, err = b.SendAnimation( - echotron.NewInputFileID(s.MediaFileID), - b.ChatID, - &echotron.AnimationOptions{ - Caption: creativeText, - ParseMode: echotron.HTML, - ReplyMarkup: keyboard, - }, - ) - } - - if err != nil { - log.Error().Err(err).Str("media_type", s.MediaType).Msg("Failed to send media") - // Fallback to text message - b.SendNew(creativeText, keyboard) - msgID = b.LastMessageID - } else if res.Result != nil { - msgID = res.Result.ID - } - } else { - // Отправляем обычное текстовое сообщение - b.SendNew(creativeText, keyboard) - msgID = b.LastMessageID - } - - // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! - s.CreativeMessageID = &msgID - log.Info().Int("creative_msg_id", *s.CreativeMessageID).Bool("has_media", s.MediaFileID != "").Msg("Creative preview sent") -} - -// updateCreativePreview обновляет превью креатива -func (s *AddCreative) updateCreativePreview(b *bot.Bot) { - if s.CreativeMessageID == nil { - log.Warn().Msg("updateCreativePreview: CreativeMessageID is nil") - return - } - - log.Info(). - Int("creative_msg_id", *s.CreativeMessageID). - Bool("has_media", s.MediaFileID != ""). - Str("media_type", s.MediaType). - Msg("Updating creative preview") - - // Удаляем оба сообщения (креатив и панель) - if s.CreativeMessageID != nil { - _, err := b.DeleteMessage(b.ChatID, *s.CreativeMessageID) - if err != nil { - log.Error().Err(err).Int("msg_id", *s.CreativeMessageID).Msg("Failed to delete old creative preview") - } - } - - if s.ControlPanelMessageID != nil { - _, err := b.DeleteMessage(b.ChatID, *s.ControlPanelMessageID) - if err != nil { - log.Error().Err(err).Int("msg_id", *s.ControlPanelMessageID).Msg("Failed to delete old control panel") - } - s.ControlPanelMessageID = nil - } - - // Отправляем оба сообщения заново в правильном порядке - s.sendCreativePreview(b) - s.showManagementPanel(b) - - log.Info().Int("new_creative_msg_id", *s.CreativeMessageID).Msg("Creative preview updated") -} - -// getCreativeText возвращает текст для превью креатива -func (s *AddCreative) getCreativeText() string { - if s.Text != nil { - return *s.Text - } - return ` Я твой креатив - -У меня пока нет текста, добавь его ниже. - -` -} - -// getCreativeButtons возвращает кнопки для превью креатива -func (s *AddCreative) getCreativeButtons() [][]echotron.InlineKeyboardButton { - var buttons [][]echotron.InlineKeyboardButton - - for _, btn := range s.Buttons { - buttons = append(buttons, []echotron.InlineKeyboardButton{ - { - Text: btn.Text, - URL: btn.URL, - }, - }) - } - - return buttons -} - -// showManagementPanel показывает панель управления креативом -func (s *AddCreative) showManagementPanel(b *bot.Bot) { - text := `☝️ Выше отображается превью вашего креатива — так он будет выглядеть при отправке - -Используйте кнопки ниже для редактирования:` - - var buttons [][]echotron.InlineKeyboardButton - - // Кнопки редактирования - row1 := []echotron.InlineKeyboardButton{} - if s.Text != nil { - row1 = append(row1, bot.Button("✏️ Текст", "edit_text")) - } else { - row1 = append(row1, bot.Button("➕ Текст", "edit_text")) - } - - if s.MediaFileID != "" { - row1 = append(row1, bot.Button("✏️ Медиа", "add_media")) - } else { - row1 = append(row1, bot.Button("➕ Медиа", "add_media")) - } - - row1 = append(row1, bot.Button("➕ Кнопка", "add_button")) - buttons = append(buttons, row1) - - // Кнопка удаления медиа (если медиа добавлено) - if s.MediaFileID != "" { - buttons = append(buttons, bot.Row( - bot.Button("🗑 Удалить медиа", "delete_media"), - )) - } - - // Кнопки удаления (если есть кнопки) - for i, btn := range s.Buttons { - buttons = append(buttons, bot.Row( - bot.Button(fmt.Sprintf("🗑 %s", btn.Text), fmt.Sprintf("delete_button:%d", i)), - )) - } - - // Финальные кнопки - if s.Text != nil { - buttons = append(buttons, bot.Row( - bot.Button("✅ Подтвердить", "confirm_create"), - bot.Button("« Отмена", "cancel"), - )) - } else { - buttons = append(buttons, bot.Row( - bot.Button("« Отмена", "cancel"), - )) - } - - s.showControlPanel(b, text, buttons) -} - -// showControlPanel редактирует панель управления с произвольным текстом и кнопками -func (s *AddCreative) showControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) { - if s.ControlPanelMessageID == nil { - // Отправляем новую панель - keyboard := bot.Keyboard(buttons...) - b.SendNew(text, keyboard) - // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! - controlPanelMsgID := b.LastMessageID - s.ControlPanelMessageID = &controlPanelMsgID - log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Control panel sent") - } else { - // Редактируем существующую панель - log.Info().Int("control_panel_msg_id", *s.ControlPanelMessageID).Msg("Editing control panel") - oldLastMessageID := b.LastMessageID - b.LastMessageID = *s.ControlPanelMessageID - - keyboard := bot.Keyboard(buttons...) - b.Edit(text, keyboard) - - b.LastMessageID = oldLastMessageID - log.Info().Msg("Control panel updated") - } + s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel") } func (s *AddCreative) createCreative(b *bot.Bot) { diff --git a/telegram_bot/screens/add_placement.go b/telegram_bot/screens/add_placement.go new file mode 100644 index 0000000..dfc5dcd --- /dev/null +++ b/telegram_bot/screens/add_placement.go @@ -0,0 +1,122 @@ +package screens + +import ( + "fmt" + + "example.com/m/adapter/backend" + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" +) + +type AddPlacement struct { + WorkspaceID string + BackState bot.State + + // Выбранные данные + SelectedProject *backend.Project + SelectedCreative *backend.Creative +} + +func (s *AddPlacement) Enter(b *bot.Bot, mode bot.RenderMode) { + text := `➕ Создать закуп + +Шаг 1: Выберите проект и креатив + +Нажмите на кнопки ниже для выбора:` + + var buttons [][]echotron.InlineKeyboardButton + + // Кнопка выбора проекта + projectButtonText := "Проект: Не выбран" + if s.SelectedProject != nil { + projectButtonText = fmt.Sprintf("Проект: %s", s.SelectedProject.Title) + } + buttons = append(buttons, bot.Row( + bot.Button(projectButtonText, "select_project"), + )) + + // Кнопка выбора креатива + creativeButtonText := "Креатив: Не выбран" + if s.SelectedCreative != nil { + creativeButtonText = fmt.Sprintf("Креатив: %s", s.SelectedCreative.Name) + } + buttons = append(buttons, bot.Row( + bot.Button(creativeButtonText, "select_creative"), + )) + + // Кнопки навигации + var navRow []echotron.InlineKeyboardButton + navRow = append(navRow, bot.Button("« Назад", "cancel")) + + // Кнопка "Далее" активна только если выбран проект + if s.SelectedProject != nil { + navRow = append(navRow, bot.Button("Далее →", "next")) + } + + buttons = append(buttons, navRow) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *AddPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch data { + case "cancel": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case "select_project": + // Переходим к выбору проекта + b.SetState(&SelectProjectForPlacement{ + WorkspaceID: s.WorkspaceID, + AddPlacementState: s, + }, bot.NewMessage) + + case "select_creative": + if s.SelectedProject == nil { + // Если проект не выбран, показываем предупреждение + b.Edit(`⚠️ Сначала выберите проект + +Для выбора креатива необходимо сначала выбрать проект, так как креативы привязаны к проектам.`, bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_add_placement")), + )) + return + } + + // Переходим к выбору креатива + b.SetState(&SelectCreativeForPlacement{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.SelectedProject.ID, + AddPlacementState: s, + }, bot.NewMessage) + + case "back_to_add_placement": + s.Enter(b, bot.EditMessage) + + case "next": + if s.SelectedProject == nil { + return + } + + // Переходим к управлению планом закупа (добавление/удаление каналов) + b.SetState(&PlacementPurchasePlan{ + WorkspaceID: s.WorkspaceID, + ProjectID: s.SelectedProject.ID, + AddPlacementState: s, + }, bot.EditMessage) + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *AddPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *AddPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go new file mode 100644 index 0000000..04fabdd --- /dev/null +++ b/telegram_bot/screens/creative_details.go @@ -0,0 +1,391 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/adapter/backend" + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type CreativeDetails struct { + CreativeEditorFields // Встраивание общих полей + + CreativeID string + ProjectID string + WorkspaceID string + BackState bot.State + + // Флаг подтверждения удаления + confirmingDelete bool +} + +func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) { + // Получаем JWT + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start", bot.Keyboard()) + return + } + + // Загружаем креатив + creative, err := b.Backend.GetCreative(context.Background(), jwt, s.WorkspaceID, s.CreativeID) + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative") + b.SendNew("❌ Не удалось загрузить креатив", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back")), + )) + return + } + + // Заполняем поля из загруженного креатива + s.Name = &creative.Name + if creative.Text != "" { + s.Text = &creative.Text + } + // Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД) + + // Отправляем превью и панель управления + s.SendCreativePreview(b) + s.showManagementPanelWithDelete(b) +} + +func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + // Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media) + switch { + case data == "cancel", data == "back": + // Отмена - возвращаемся назад + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case data == "edit_text": + s.ShowControlPanel(b, `✎ Введите новый текст креатива: + +⚠ Важно: Текст должен содержать ОДНУ инвайт-ссылку вашего канала + +Формат ссылки: +https://t.me/+xxx + +Пример: +Присоединяйтесь к нашему каналу! +https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена редактирования", "cancel_edit")), + }) + + case data == "add_button": + s.WaitingForButtonText = true + s.ShowControlPanel(b, `+ Добавить кнопку + +Введите текст кнопки: + +Например: +• Перейти на сайт +• Написать нам`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + + case data == "add_media": + s.WaitingForMedia = true + s.ShowControlPanel(b, `⧉ Добавить медиа + +Отправьте фото, видео или GIF + +Это медиа будет отображаться в креативе вместе с текстом`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_media")), + }) + + case data == "delete_media": + s.MediaType = "" + s.MediaFileID = "" + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + + 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" { + s.Buttons = s.Buttons[:len(s.Buttons)-1] + s.UpdateCreativePreview(b) + } + } + s.WaitingForButtonText = false + s.WaitingForButtonURL = false + s.CurrentButtonText = nil + s.showManagementPanelWithDelete(b) + + case data == "cancel_edit", data == "cancel_media": + s.WaitingForMedia = false + s.showManagementPanelWithDelete(b) + + case data == "confirm_save": + s.updateCreative(b) + + case data == "delete_creative": + if !s.confirmingDelete { + // Первое нажатие - просим подтверждение + s.confirmingDelete = true + s.showManagementPanelWithDelete(b) + } else { + // Второе нажатие - удаляем + s.deleteCreative(b) + } + + case data == "confirm_delete": + // Это только для безопасности, главная логика в delete_creative + s.deleteCreative(b) + + case strings.HasPrefix(data, "delete_button:"): + parts := strings.Split(data, ":") + if len(parts) == 2 { + var index int + if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil { + if index >= 0 && index < len(s.Buttons) { + s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + } + } + } + + default: + // Сбрасываем флаг подтверждения удаления при любом другом действии + if s.confirmingDelete { + s.confirmingDelete = false + s.showManagementPanelWithDelete(b) + } + } +} + +func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil { + return + } + + // Обрабатываем добавление медиа + if s.WaitingForMedia { + 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.WaitingForMedia = false + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + return + + } else if u.Message.Video != nil { + s.MediaType = "video" + s.MediaFileID = u.Message.Video.FileID + s.WaitingForMedia = false + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + return + + } else if u.Message.Animation != nil { + s.MediaType = "animation" + s.MediaFileID = u.Message.Animation.FileID + s.WaitingForMedia = false + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + return + } + } + + // Обрабатываем ввод текста кнопки + if s.WaitingForButtonText && u.Message.Text != "" { + buttonText := u.Message.Text + s.CurrentButtonText = &buttonText + s.WaitingForButtonText = false + s.WaitingForButtonURL = true + + // Создаём кнопку с placeholder URL + s.Buttons = append(s.Buttons, InlineButton{ + Text: buttonText, + URL: "https://example.com", + }) + + s.UpdateCreativePreview(b) + + s.ShowControlPanel(b, `✧ Введите URL для кнопки: + +Например: +https://example.com`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + return + } + + // Обрабатываем ввод URL кнопки + if s.WaitingForButtonURL && u.Message.Text != "" { + url := strings.TrimSpace(u.Message.Text) + + // Простая валидация URL + if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") { + s.ShowControlPanel(b, `❌ Неверный формат URL + +URL должен начинаться с http:// или https:// + +Попробуйте ещё раз:`, [][]echotron.InlineKeyboardButton{ + bot.Row(bot.Button("« Отмена", "cancel_add_button")), + }) + return + } + + // Обновляем URL последней кнопки + if len(s.Buttons) > 0 { + s.Buttons[len(s.Buttons)-1].URL = url + } + + s.WaitingForButtonURL = false + s.CurrentButtonText = nil + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + return + } + + // Обрабатываем редактирование текста креатива + if !s.WaitingForButtonText && !s.WaitingForButtonURL && !s.WaitingForMedia && u.Message.Text != "" { + text := s.GetFormattedText(u.Message) + s.Text = &text + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + return + } + + // Обрабатываем имя креатива (если нет других состояний) + if u.Message.Text != "" { + text := s.GetFormattedText(u.Message) + s.Text = &text + + s.UpdateCreativePreview(b) + s.showManagementPanelWithDelete(b) + } +} + +func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) { + var confirmText, confirmCallback string + + if s.confirmingDelete { + confirmText = "✖ Подтвердить удаление" + confirmCallback = "confirm_delete" + } else { + confirmText = "✓ Сохранить" + confirmCallback = "confirm_save" + } + + // Добавляем кнопку удаления креатива + deleteButton := bot.Row(bot.Button("⌦ Удалить креатив", "delete_creative")) + + s.ShowManagementPanel(b, confirmText, confirmCallback, "cancel", deleteButton) +} + +func (s *CreativeDetails) updateCreative(b *bot.Bot) { + // Получаем JWT + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start", bot.Keyboard()) + return + } + + // Подготавливаем данные для обновления + input := backend.UpdateCreativeInput{ + Name: s.Name, + Text: s.Text, + } + + // Отправляем запрос на обновление + _, err = b.Backend.UpdateCreative( + context.Background(), + jwt, + s.WorkspaceID, + s.CreativeID, + input, + ) + + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative") + b.SendNew("❌ Не удалось сохранить изменения", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back")), + )) + return + } + + log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully") + + // Возвращаемся назад + if s.BackState != nil { + b.SendNew("✅ Креатив успешно обновлен!", bot.Keyboard()) + b.SetState(s.BackState, bot.NewMessage) + } +} + +func (s *CreativeDetails) deleteCreative(b *bot.Bot) { + // Получаем JWT + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации\n\nПопробуйте /start", bot.Keyboard()) + return + } + + // Отправляем запрос на удаление + err = b.Backend.DeleteCreative( + context.Background(), + jwt, + s.WorkspaceID, + s.CreativeID, + ) + + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to delete creative") + b.SendNew("❌ Не удалось удалить креатив", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back")), + )) + return + } + + log.Info().Str("creative_id", s.CreativeID).Msg("Creative deleted successfully") + + // Возвращаемся назад + if s.BackState != nil { + b.SendNew("✅ Креатив успешно удален!", bot.Keyboard()) + b.SetState(s.BackState, bot.NewMessage) + } +} + +func (s *CreativeDetails) ensureJWT(b *bot.Bot) (string, error) { + // Проверяем есть ли JWT в сессии + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + // Если нет, запрашиваем новый + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + // Сохраняем в сессию + b.Session.JWT = jwt + return jwt, nil +} diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go new file mode 100644 index 0000000..ac8513b --- /dev/null +++ b/telegram_bot/screens/creative_editor.go @@ -0,0 +1,394 @@ +package screens + +import ( + "fmt" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +// InlineButton представляет кнопку с URL +type InlineButton struct { + Text string + URL string +} + +// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива +type CreativeEditorFields struct { + // Данные креатива + Name *string + Text *string + Buttons []InlineButton + + // Медиа + MediaType string // "photo", "video", "animation" + MediaFileID string // File ID из Telegram + + // Состояние для добавления кнопки + WaitingForButtonText bool + WaitingForButtonURL bool + CurrentButtonText *string + WaitingForMedia bool + + // ID сообщения с креативом (которое постоянно обновляется) + CreativeMessageID *int + // ID сообщения с панелью управления + ControlPanelMessageID *int + + // Предыдущее состояние для определения изменения типа сообщения + previousHasMedia bool +} + +// GetCreativeText возвращает текст для превью креатива +func (e *CreativeEditorFields) GetCreativeText() string { + if e.Text != nil && *e.Text != "" { + return *e.Text + } + return `💭 Я твой креатив + +У меня пока нет текста, добавь его ниже. + +` +} + +// GetCreativeButtons возвращает кнопки для превью креатива +func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton { + var buttons [][]echotron.InlineKeyboardButton + + for _, btn := range e.Buttons { + buttons = append(buttons, []echotron.InlineKeyboardButton{ + { + Text: btn.Text, + URL: btn.URL, + }, + }) + } + + return buttons +} + +// SendCreativePreview отправляет превью креатива +func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) { + creativeText := e.GetCreativeText() + creativeButtons := e.GetCreativeButtons() + + // Создаем клавиатуру с пустым массивом (не nil!) + keyboard := echotron.InlineKeyboardMarkup{ + InlineKeyboard: creativeButtons, + } + if len(creativeButtons) == 0 { + keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} + } + + var msgID int + + // Если есть медиа, отправляем с медиа + if e.MediaFileID != "" { + var res echotron.APIResponseMessage + var err error + + switch e.MediaType { + case "photo": + res, err = b.SendPhoto( + echotron.NewInputFileID(e.MediaFileID), + b.ChatID, + &echotron.PhotoOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + case "video": + res, err = b.SendVideo( + echotron.NewInputFileID(e.MediaFileID), + b.ChatID, + &echotron.VideoOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + case "animation": + res, err = b.SendAnimation( + echotron.NewInputFileID(e.MediaFileID), + b.ChatID, + &echotron.AnimationOptions{ + Caption: creativeText, + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + } + + 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, + }) + if err == nil && res.Result != nil { + msgID = res.Result.ID + } + } else if res.Result != nil { + msgID = res.Result.ID + } + } else { + // Отправляем обычное текстовое сообщение напрямую (не через SendNew) + // чтобы не обновлять LastMessageID и не затронуть cleanup + res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{ + ReplyMarkup: keyboard, + ParseMode: echotron.HTML, + }) + if err != nil { + log.Error().Err(err).Msg("Failed to send creative preview") + return + } + if res.Result != nil { + msgID = res.Result.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") +} + +// UpdateCreativePreview обновляет превью креатива +func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) { + if e.CreativeMessageID == nil { + log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil") + // Если превью еще не создано, создаем его + e.SendCreativePreview(b) + return + } + + 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) + e.CreativeMessageID = nil + } + if e.ControlPanelMessageID != nil { + b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID) + e.ControlPanelMessageID = nil + } + + // Создаем превью заново (панель будет создана через ShowManagementPanel после этого) + e.SendCreativePreview(b) + return + } + + // Тип не изменился - просто редактируем на месте + creativeText := e.GetCreativeText() + creativeButtons := e.GetCreativeButtons() + + keyboard := echotron.InlineKeyboardMarkup{ + InlineKeyboard: creativeButtons, + } + if len(creativeButtons) == 0 { + keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{} + } + + msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID) + + // Если есть медиа - редактируем caption + 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") + } else { + e.previousHasMedia = currentHasMedia + } + } else { + // Текстовое сообщение - редактируем текст + _, err := b.EditMessageText( + creativeText, + msgID, + &echotron.MessageTextOptions{ + ParseMode: echotron.HTML, + ReplyMarkup: keyboard, + }, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to edit message text") + } else { + e.previousHasMedia = currentHasMedia + } + } +} + +// ShowManagementPanel показывает панель управления креативом +// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить") +// confirmCallback - callback для кнопки подтверждения +// cancelCallback - callback для кнопки отмены +// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками +func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) { + text := `👆Превью креатива выше — так он будет выглядеть при отправке. + +Выберите, что изменить:` + + 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 e.MediaFileID != "" { + buttons = append(buttons, bot.Row( + bot.Button("⌫ Убрать медиа", "delete_media"), + )) + } + + // Кнопки удаления (если есть кнопки) + if len(e.Buttons) > 0 { + var row []echotron.InlineKeyboardButton + for i, btn := range e.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) + } + } + + // Дополнительные кнопки (например "Удалить креатив") + for _, extraRow := range extraButtons { + buttons = append(buttons, extraRow) + } + + // Финальные кнопки + if e.Text != nil { + buttons = append(buttons, bot.Row( + bot.Button("← Назад", cancelCallback), + bot.Button(confirmButtonText, confirmCallback), + )) + } else { + buttons = append(buttons, bot.Row( + bot.Button("← Назад", cancelCallback), + )) + } + + e.ShowControlPanel(b, text, buttons) +} + +// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками +func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) { + if e.ControlPanelMessageID == nil { + // Отправляем новую панель + keyboard := bot.Keyboard(buttons...) + b.SendNew(text, keyboard) + // ВАЖНО: Сохраняем копию значения, а не указатель на переменную! + 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") + oldLastMessageID := b.LastMessageID + b.LastMessageID = *e.ControlPanelMessageID + + keyboard := bot.Keyboard(buttons...) + b.Edit(text, keyboard) + + b.LastMessageID = oldLastMessageID + log.Info().Msg("Control panel updated") + } +} + +// GetFormattedText извлекает форматированный текст из сообщения с entities +func (e *CreativeEditorFields) GetFormattedText(message *echotron.Message) string { + if message.Text == "" { + return "" + } + + // Если нет entities - возвращаем текст как есть + if len(message.Entities) == 0 { + return message.Text + } + + // Преобразуем текст в руны для правильной работы с UTF-8 + runes := []rune(message.Text) + result := "" + lastPos := 0 + + // Обрабатываем entities + for _, entity := range message.Entities { + // Добавляем текст до entity + if entity.Offset > lastPos { + result += string(runes[lastPos:entity.Offset]) + } + + // Получаем текст entity + entityText := string(runes[entity.Offset : entity.Offset+entity.Length]) + + // Оборачиваем в соответствующие HTML-теги + switch entity.Type { + case "bold": + result += "" + entityText + "" + case "italic": + result += "" + entityText + "" + case "underline": + result += "" + entityText + "" + case "strikethrough": + result += "" + entityText + "" + case "code": + result += "" + entityText + "" + case "pre": + result += "
" + entityText + "
" + case "text_link": + if entity.URL != "" { + result += fmt.Sprintf("%s", entity.URL, entityText) + } else { + result += entityText + } + case "url": + result += entityText + default: + result += entityText + } + + lastPos = entity.Offset + entity.Length + } + + // Добавляем оставшийся текст + if lastPos < len(runes) { + result += string(runes[lastPos:]) + } + + return result +} diff --git a/telegram_bot/screens/creatives.go b/telegram_bot/screens/creatives.go index afaabb9..1aea3a1 100644 --- a/telegram_bot/screens/creatives.go +++ b/telegram_bot/screens/creatives.go @@ -46,67 +46,91 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) return } - text := "Креативы • Управление рекламными материалами\n\n" - + var text string var buttons [][]echotron.InlineKeyboardButton if len(page.Items) == 0 { - text += "У вас пока нет креативов" + text = `Креативы + +У вас пока нет креативов. + +Креатив — это рекламный материал: +▸ Текст объявления +▸ Медиа контент (фото, видео) +▸ Шаблон для автопостинга +▸ Варианты для A/B тестирования + +Начните с добавления первого креатива 👇` } else { - text += fmt.Sprintf("Выберите креатив для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) - - // Креативы по 2 в ряду - for i := 0; i < len(page.Items); i += 2 { - var row []echotron.InlineKeyboardButton - - // Первый креатив в ряду - creative := page.Items[i] - row = append(row, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) - - // Второй креатив в ряду (если есть) - if i+1 < len(page.Items) { - creative = page.Items[i+1] - row = append(row, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) - } else { - // Добавляем пустую кнопку для выравнивания - row = append(row, bot.Button(" ", "empty")) - } - - buttons = append(buttons, row) + pageInfo := "" + if page.Pages > 1 { + pageInfo = fmt.Sprintf("\nСтраница %d из %d", page.Page, page.Pages) } - // Кнопки пагинации с номером страницы посередине (только если есть креативы) - var navRow []echotron.InlineKeyboardButton + text = fmt.Sprintf(`Креативы - // Левая стрелка (активная или неактивная) - if s.CurrentPage > 0 { - navRow = append(navRow, bot.Button("←", "prev")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } +Управление рекламными материалами.%s - // Кнопка с номером страницы (неактивная) - pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") - navRow = append(navRow, pageButton) - - // Правая стрелка (активная или неактивная) - if s.CurrentPage+1 < page.Pages { - navRow = append(navRow, bot.Button("→", "next")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } - - buttons = append(buttons, navRow) +Выберите креатив 👇`, + pageInfo) } - // Кнопки управления - buttons = append(buttons, bot.Row( - bot.Button("Архив", "archive"), - bot.Button("Добавить креатив", "add_creative"), - )) + // Создаем слоты для креативов + var slots []echotron.InlineKeyboardButton + for _, creative := range page.Items { + slots = append(slots, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) + } - // Кнопка возврата - buttons = append(buttons, bot.Row(bot.Button("« Назад", "back"))) + // Если страниц >= 2, заполняем до 6 слотов пустыми кнопками + if page.Pages >= 2 { + for len(slots) < creativesPerPage { + slots = append(slots, bot.Button(" ", "empty")) + } + } + + // Разбиваем слоты по 2 в ряду + for i := 0; i < len(slots); i += 2 { + var row []echotron.InlineKeyboardButton + row = append(row, slots[i]) + + // Второй слот в ряду (если есть) + if i+1 < len(slots) { + row = append(row, slots[i+1]) + } else { + // Если нечетное количество, добавляем пустую кнопку + row = append(row, bot.Button(" ", "empty")) + } + + buttons = append(buttons, row) + } + + // Ряд: Пагинация + Добавить + var navRow []echotron.InlineKeyboardButton + + // Левая стрелка (или пустая кнопка) + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } else { + navRow = append(navRow, bot.Button(" ", "empty")) + } + + // Кнопка "Добавить креатив" всегда в центре + navRow = append(navRow, bot.Button("+ Добавить", "add_creative")) + + // Правая стрелка (или пустая кнопка) + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } else { + navRow = append(navRow, bot.Button(" ", "empty")) + } + + buttons = append(buttons, navRow) + + // Нижний ряд: Назад + Архив + buttons = append(buttons, bot.Row( + bot.Button("← Назад", "back"), + bot.Button("≡ Архив", "archive"), + )) keyboard := bot.Keyboard(buttons...) b.Render(text, keyboard, mode) @@ -175,10 +199,12 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { if len(parts) == 2 { creativeID := parts[1] - // TODO: реализовать экран деталей креатива - b.Edit(fmt.Sprintf("📄 Детали креатива\n\nID: %s\n\nЭта функция в разработке", creativeID), bot.Keyboard( - bot.Row(bot.Button("« Назад", "back")), - )) + b.SetState(&CreativeDetails{ + CreativeID: creativeID, + ProjectID: s.ProjectID, + WorkspaceID: s.WorkspaceID, + BackState: s, + }, bot.NewMessage) } default: diff --git a/telegram_bot/screens/main_menu.go b/telegram_bot/screens/main_menu.go index db6d33a..5980bcf 100644 --- a/telegram_bot/screens/main_menu.go +++ b/telegram_bot/screens/main_menu.go @@ -23,7 +23,7 @@ func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { case "support": // b.SetState(&Support{}, bot.NewMessage) case "placements": - // b.SetState(&Placements{}, bot.NewMessage) + b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.EditMessage) case "dashboard": // b.SetState(&Dashboard{}, bot.NewMessage) default: diff --git a/telegram_bot/screens/my_placements.go b/telegram_bot/screens/my_placements.go new file mode 100644 index 0000000..6be70a7 --- /dev/null +++ b/telegram_bot/screens/my_placements.go @@ -0,0 +1,221 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +const placementsPerPage = 6 + +type MyPlacements struct { + CurrentPage int + WorkspaceID string + BackState bot.State +} + +func (s *MyPlacements) Enter(b *bot.Bot, mode bot.RenderMode) { + // Получаем или запрашиваем JWT + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", bot.Keyboard()) + return + } + + // Если workspace еще не выбран, получаем список workspace'ов + if s.WorkspaceID == "" { + workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt) + if err != nil { + log.Error().Err(err).Msg("Failed to get workspaces") + b.SendNew("❌ Не удалось загрузить workspace'ы", bot.Keyboard()) + return + } + + if len(workspaces) == 0 { + b.SendNew("У вас пока нет workspace'ов", bot.Keyboard()) + return + } + + // Используем первый workspace по умолчанию + s.WorkspaceID = workspaces[0].ID + } + + s.renderPlacements(b, mode, jwt) +} + +func (s *MyPlacements) renderPlacements(b *bot.Bot, mode bot.RenderMode, jwt string) { + page, err := b.Backend.GetPlacements( + context.Background(), + jwt, + s.WorkspaceID, + nil, // projectID + nil, // placementChannelID + nil, // creativeID + false, // includeArchived + s.CurrentPage+1, + placementsPerPage, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get placements") + b.SendNew("❌ Не удалось загрузить закупы", bot.Keyboard()) + return + } + + text := "Мои закупы • Управление размещениями\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(page.Items) == 0 { + text += "У вас пока нет закупов" + } else { + text += fmt.Sprintf("Выберите закуп для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) + + // Закупы по 2 в ряду + for i := 0; i < len(page.Items); i += 2 { + var row []echotron.InlineKeyboardButton + + // Первый закуп в ряду + placement := page.Items[i] + row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID))) + + // Второй закуп в ряду (если есть) + if i+1 < len(page.Items) { + placement = page.Items[i+1] + row = append(row, bot.Button(placement.Name, fmt.Sprintf("placement:%s", placement.ID))) + } else { + // Добавляем пустую кнопку для выравнивания + row = append(row, bot.Button(" ", "empty")) + } + + buttons = append(buttons, row) + } + + // Кнопки пагинации с номером страницы посередине (только если есть закупы) + var navRow []echotron.InlineKeyboardButton + + // Левая стрелка (активная или неактивная) + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + // Кнопка с номером страницы (неактивная) + pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") + navRow = append(navRow, pageButton) + + // Правая стрелка (активная или неактивная) + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + buttons = append(buttons, navRow) + } + + // Кнопки управления + buttons = append(buttons, bot.Row( + bot.Button("Статистика", "statistics"), + bot.Button("Создать закуп", "add_placement"), + )) + + // Кнопка возврата в главное меню + buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu"))) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *MyPlacements) ensureJWT(b *bot.Bot) (string, error) { + // Проверяем, есть ли JWT в сессии + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + // Если нет, запрашиваем новый + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + // Сохраняем в сессию + b.Session.JWT = jwt + return jwt, nil +} + +func (s *MyPlacements) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "menu": + b.SetState(&MainMenu{}, bot.EditMessage) + + case data == "page_info", data == "empty": + // Неактивные кнопки - ничего не делаем + return + + case data == "statistics": + // TODO: реализовать экран статистики + b.Edit("📊 Статистика закупов\n\nЭта функция в разработке", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_placements")), + )) + + case data == "add_placement": + b.SetState(&AddPlacement{ + WorkspaceID: s.WorkspaceID, + BackState: s, + }, bot.EditMessage) + + case data == "back_to_placements": + s.Enter(b, bot.EditMessage) + + case strings.HasPrefix(data, "placement:"): + // Открытие деталей закупа + parts := strings.Split(data, ":") + if len(parts) == 2 { + placementID := parts[1] + + // TODO: реализовать экран деталей закупа + b.Edit(fmt.Sprintf("📄 Детали закупа\n\nID: %s\n\nЭта функция в разработке", placementID), bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_placements")), + )) + } + + case strings.HasPrefix(data, "workspace:"): + // Позволяет переключаться между workspace'ами в будущем + parts := strings.Split(data, ":") + if len(parts) == 2 { + s.WorkspaceID = parts[1] + s.CurrentPage = 0 + s.Enter(b, bot.EditMessage) + } + + default: + s.Enter(b, bot.NewMessage) + } + return +} + +func (s *MyPlacements) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *MyPlacements) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/my_projects.go b/telegram_bot/screens/my_projects.go index fb2de03..c187f37 100644 --- a/telegram_bot/screens/my_projects.go +++ b/telegram_bot/screens/my_projects.go @@ -56,67 +56,92 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) return } - text := "Страница с проектами • Управление каналами\n\n" - + var text string var buttons [][]echotron.InlineKeyboardButton if len(page.Items) == 0 { - text += "У вас пока нет проектов" + text = `Мои проекты + +У вас пока нет подключенных каналов. + +Что дает подключение канала: +▸ Управление типом приглашений (публичные/с одобрением) +▸ Автоматическая отправка креативов в канал +▸ Интеграция с планом закупов +▸ Статистика и аналитика подписчиков +▸ Настройка уведомлений + +Начните с добавления первого проекта ↓` } else { - text += fmt.Sprintf("Выберите проект для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) - - // Проекты по 2 в ряду - for i := 0; i < len(page.Items); i += 2 { - var row []echotron.InlineKeyboardButton - - // Первый проект в ряду - project := page.Items[i] - row = append(row, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID))) - - // Второй проект в ряду (если есть) - if i+1 < len(page.Items) { - project = page.Items[i+1] - row = append(row, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID))) - } else { - // Добавляем пустую кнопку для выравнивания - row = append(row, bot.Button(" ", "empty")) - } - - buttons = append(buttons, row) + pageInfo := "" + if page.Pages > 1 { + pageInfo = fmt.Sprintf("\nСтраница %d из %d", page.Page, page.Pages) } - // Кнопки пагинации с номером страницы посередине (только если есть проекты) - var navRow []echotron.InlineKeyboardButton + text = fmt.Sprintf(`Мои проекты - // Левая стрелка (активная или неактивная) - if s.CurrentPage > 0 { - navRow = append(navRow, bot.Button("←", "prev")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } +Управление вашими Telegram-каналами.%s - // Кнопка с номером страницы (неактивная) - pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") - navRow = append(navRow, pageButton) - - // Правая стрелка (активная или неактивная) - if s.CurrentPage+1 < page.Pages { - navRow = append(navRow, bot.Button("→", "next")) - } else { - navRow = append(navRow, bot.Button("·", "empty")) - } - - buttons = append(buttons, navRow) +Выберите канал 👇 `, + pageInfo) } - // Кнопки управления - buttons = append(buttons, bot.Row( - bot.Button("Архив", "archive"), - bot.Button("Добавить проект", "add_project"), - )) + // Создаем слоты для проектов + var slots []echotron.InlineKeyboardButton + for _, project := range page.Items { + slots = append(slots, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID))) + } - // Кнопка возврата в главное меню - buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu"))) + // Если страниц >= 2, заполняем до 6 слотов пустыми кнопками + if page.Pages >= 2 { + for len(slots) < projectsPerPage { + slots = append(slots, bot.Button(" ", "empty")) + } + } + + // Разбиваем слоты по 2 в ряду + for i := 0; i < len(slots); i += 2 { + var row []echotron.InlineKeyboardButton + row = append(row, slots[i]) + + // Второй слот в ряду (если есть) + if i+1 < len(slots) { + row = append(row, slots[i+1]) + } else { + // Если нечетное количество, добавляем пустую кнопку + row = append(row, bot.Button(" ", "empty")) + } + + buttons = append(buttons, row) + } + + // Ряд: Пагинация + Добавить + var navRow []echotron.InlineKeyboardButton + + // Левая стрелка (или пустая кнопка) + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } else { + navRow = append(navRow, bot.Button(" ", "empty")) + } + + // Кнопка "Добавить проект" всегда в центре + navRow = append(navRow, bot.Button("+ Добавить", "add_project")) + + // Правая стрелка (или пустая кнопка) + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } else { + navRow = append(navRow, bot.Button(" ", "empty")) + } + + buttons = append(buttons, navRow) + + // Нижний ряд: Назад + Архив + buttons = append(buttons, bot.Row( + bot.Button("← Назад", "menu"), + bot.Button("≡ Архив", "archive"), + )) keyboard := bot.Keyboard(buttons...) b.Render(text, keyboard, mode) @@ -157,7 +182,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { s.CurrentPage++ s.Enter(b, bot.EditMessage) - case data == "menu": + case data == "menu", data == "main_menu": b.SetState(&MainMenu{}, bot.EditMessage) case data == "page_info", data == "empty": diff --git a/telegram_bot/screens/placement_purchase_plan.go b/telegram_bot/screens/placement_purchase_plan.go new file mode 100644 index 0000000..3fc5fde --- /dev/null +++ b/telegram_bot/screens/placement_purchase_plan.go @@ -0,0 +1,222 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/adapter/backend" + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +type PlacementPurchasePlan struct { + WorkspaceID string + ProjectID string + AddPlacementState *AddPlacement +} + +func (s *PlacementPurchasePlan) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + s.renderChannels(b, mode, jwt) +} + +func (s *PlacementPurchasePlan) renderChannels(b *bot.Bot, mode bot.RenderMode, jwt string) { + channels, err := b.Backend.GetPurchasePlanChannels( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get purchase plan channels") + b.SendNew("❌ Не удалось загрузить каналы", bot.Keyboard()) + return + } + + text := "📋 План закупа — Каналы\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(channels) == 0 { + text += "Список каналов пуст.\n\n" + } else { + text += "Нажмите на канал для удаления:\n\n" + + for _, ch := range channels { + channelName := "Без названия" + if ch.Channel.Title != nil { + channelName = *ch.Channel.Title + } else if ch.Channel.Username != nil { + channelName = "@" + *ch.Channel.Username + } + + // Показываем статус и стоимость + statusEmoji := "📌" + switch ch.Status { + case "planned": + statusEmoji = "📝" + case "approved": + statusEmoji = "✅" + case "rejected": + statusEmoji = "❌" + case "in_progress": + statusEmoji = "🔄" + case "completed": + statusEmoji = "✔️" + } + + buttonText := fmt.Sprintf("%s %s", statusEmoji, channelName) + if ch.PlannedCost != nil { + buttonText += fmt.Sprintf(" (%.0f₽)", *ch.PlannedCost) + } + + buttons = append(buttons, bot.Row( + bot.Button(buttonText, fmt.Sprintf("remove:%s", ch.ID)), + )) + } + } + + // Инструкция по добавлению канала + text += "\nДобавление канала:\nОтправьте username канала (без @)" + + // Навигация + var navRow []echotron.InlineKeyboardButton + navRow = append(navRow, bot.Button("« Назад", "back")) + navRow = append(navRow, bot.Button("Далее →", "next")) + buttons = append(buttons, navRow) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *PlacementPurchasePlan) ensureJWT(b *bot.Bot) (string, error) { + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + b.Session.JWT = jwt + return jwt, nil +} + +func (s *PlacementPurchasePlan) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "back": + if s.AddPlacementState != nil { + b.SetState(s.AddPlacementState, bot.EditMessage) + } + + case data == "next": + // TODO: переход к следующему шагу (детали размещения) + b.Edit("🚧 Следующий шаг в разработке", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_purchase_plan")), + )) + + case data == "back_to_purchase_plan": + s.Enter(b, bot.EditMessage) + + case strings.HasPrefix(data, "remove:"): + parts := strings.Split(data, ":") + if len(parts) == 2 { + purchasePlanChannelID := parts[1] // ID записи PurchasePlanChannel + + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + return + } + + // Удаляем канал из плана закупа + err = b.Backend.RemoveChannelFromPurchasePlan( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + purchasePlanChannelID, + ) + if err != nil { + log.Error().Err(err).Str("purchase_plan_channel_id", purchasePlanChannelID).Msg("Failed to remove channel") + b.Edit("❌ Не удалось удалить канал", bot.Keyboard( + bot.Row(bot.Button("« Назад", "back_to_purchase_plan")), + )) + return + } + + // Обновляем экран + s.Enter(b, bot.EditMessage) + } + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *PlacementPurchasePlan) HandleMessage(b *bot.Bot, u *echotron.Update) { + if u.Message == nil || u.Message.Text == "" { + return + } + + username := strings.TrimSpace(u.Message.Text) + + // Убираем @ если пользователь его добавил + username = strings.TrimPrefix(username, "@") + + if username == "" { + return + } + + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + // Отправляем запрос на добавление канала + input := backend.AttachChannelToPurchasePlanInput{ + Username: username, + } + + _, err = b.Backend.AttachChannelToPurchasePlan( + context.Background(), + jwt, + s.WorkspaceID, + s.ProjectID, + input, + ) + + if err != nil { + log.Error().Err(err).Str("username", username).Msg("Failed to attach channel") + + // Проверяем, содержит ли ошибка информацию о том, что канал не найден + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") { + b.SendNew(fmt.Sprintf("❌ Канал @%s не найден в Telegram.\n\nПроверьте правильность username и попробуйте еще раз.", username), bot.Keyboard()) + } else { + b.SendNew("❌ Не удалось добавить канал. Попробуйте позже.", bot.Keyboard()) + } + return + } + + // Обновляем экран со списком каналов + s.Enter(b, bot.NewMessage) +} + +func (s *PlacementPurchasePlan) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/project_details.go b/telegram_bot/screens/project_details.go index c030a68..793025b 100644 --- a/telegram_bot/screens/project_details.go +++ b/telegram_bot/screens/project_details.go @@ -34,38 +34,33 @@ func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *ProjectDetails) formatProjectDetails() string { p := s.Project - // Заголовок - text := fmt.Sprintf(`%s`, p.Title) + // Заголовок с названием проекта + text := fmt.Sprintf("%s\n\n", p.Title) // Информация о канале if p.Username != nil && *p.Username != "" { - text += fmt.Sprintf(` -Канал: @%s`, *p.Username) + text += fmt.Sprintf("Telegram-канал: @%s\n", *p.Username) } else { - text += ` -Канал: не привязан` + text += "Telegram-канал: не привязан\n" } // Статус проекта var statusText string switch p.Status { case "active": - statusText = "Активный" + statusText = "✓ Активный" case "inactive": - statusText = "Неактивен" + statusText = "○ Неактивен" case "archived": - statusText = "Архивный" + statusText = "▣ Архивный" case "paused": - statusText = "Приостановлен" + statusText = "⊙ Приостановлен" default: statusText = p.Status } - text += fmt.Sprintf(` -Статус: %s`, statusText) + text += fmt.Sprintf("Статус: %s\n", statusText) - // ID проекта (для технических целей) - text += fmt.Sprintf(` -ID: %s`, p.ID) + text += "\nУправление проектом 👇" return text } @@ -76,7 +71,7 @@ func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup { // Первый ряд: Креативы и Создать закуп buttons = append(buttons, bot.Row( bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), - bot.Button("Создать закуп", fmt.Sprintf("create_purchase:%s", s.Project.ID)), + bot.Button("Создать закуп", fmt.Sprintf("create_placement:%s", s.Project.ID)), )) // Второй ряд: План закупов @@ -84,41 +79,35 @@ func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup { bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)), )) - // Третий ряд: Тип ссылки по умолчанию + // Третий ряд: Тип приглашений (с подтверждением) var linkTypeButtonText string if s.confirmingLinkTypeChange { - // Показываем текст подтверждения с противоположным типом + // Показываем подтверждение var newType string if s.Project.InviteLinkType == "public" { newType = "С одобрением" } else { newType = "Открытая" } - linkTypeButtonText = fmt.Sprintf("Нажмите еще раз → %s", newType) + linkTypeButtonText = fmt.Sprintf("Подтвердить → %s", newType) } else { - // Показываем текущий тип + // Показываем текущий тип приглашений var currentType string if s.Project.InviteLinkType == "public" { currentType = "Открытая" } else { currentType = "С одобрением" } - linkTypeButtonText = fmt.Sprintf("Тип ссылки: %s", currentType) + linkTypeButtonText = fmt.Sprintf("Приглашения: %s", currentType) } buttons = append(buttons, bot.Row( bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)), )) - // Четвертый ряд: Команда и Архивировать + // Нижний ряд: Назад и Архивировать buttons = append(buttons, bot.Row( - bot.Button("👥 Команда", fmt.Sprintf("team:%s", s.Project.ID)), - bot.Button("Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)), - )) - - // Пятый ряд: Назад и Главное меню - buttons = append(buttons, bot.Row( - bot.Button("Назад", "back_to_projects"), - bot.Button("Главное меню", "main_menu"), + bot.Button("← Назад", "back_to_projects"), + bot.Button("≡ Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)), )) return bot.Keyboard(buttons...) @@ -138,10 +127,6 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { b.SetState(s.BackState, bot.EditMessage) } - case data == "main_menu": - s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения - b.SetState(&MainMenu{}, bot.NewMessage) - case strings.HasPrefix(data, "creatives:"): s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения b.SetState(&Creatives{ @@ -150,6 +135,13 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { BackState: s, }, bot.NewMessage) + case strings.HasPrefix(data, "create_placement:"): + s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения + // TODO: реализовать создание закупа + b.Edit("🚧 Создать закуп\n\nФункция в разработке", bot.Keyboard( + bot.Row(bot.Button("← Назад", "back_to_project")), + )) + case data == "back_to_project": s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.Enter(b, bot.EditMessage) diff --git a/telegram_bot/screens/select_creative_for_placement.go b/telegram_bot/screens/select_creative_for_placement.go new file mode 100644 index 0000000..3cee7a0 --- /dev/null +++ b/telegram_bot/screens/select_creative_for_placement.go @@ -0,0 +1,188 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +const creativesForPlacementPerPage = 6 + +type SelectCreativeForPlacement struct { + WorkspaceID string + ProjectID string + CurrentPage int + AddPlacementState *AddPlacement +} + +func (s *SelectCreativeForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + s.renderCreatives(b, mode, jwt) +} + +func (s *SelectCreativeForPlacement) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) { + page, err := b.Backend.GetCreatives( + context.Background(), + jwt, + s.WorkspaceID, + &s.ProjectID, + false, // includeArchived + s.CurrentPage+1, + creativesForPlacementPerPage, + ) + if err != nil { + log.Error().Err(err).Msg("Failed to get creatives") + b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard()) + return + } + + text := "Выбор креатива\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(page.Items) == 0 { + text += "У выбранного проекта пока нет креативов.\n\nСоздайте креатив через меню проекта." + } else { + text += fmt.Sprintf("Выберите креатив (стр. %d из %d):", s.CurrentPage+1, page.Pages) + + // Креативы по 2 в ряду + for i := 0; i < len(page.Items); i += 2 { + var row []echotron.InlineKeyboardButton + + // Первый креатив в ряду + creative := page.Items[i] + row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID))) + + // Второй креатив в ряду (если есть) + if i+1 < len(page.Items) { + creative = page.Items[i+1] + row = append(row, bot.Button(creative.Name, fmt.Sprintf("select:%s", creative.ID))) + } else { + row = append(row, bot.Button(" ", "empty")) + } + + buttons = append(buttons, row) + } + + // Кнопки пагинации + var navRow []echotron.InlineKeyboardButton + + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") + navRow = append(navRow, pageButton) + + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + buttons = append(buttons, navRow) + } + + // Опция пропустить выбор креатива (креатив опционален) + buttons = append(buttons, bot.Row(bot.Button("Без креатива", "skip"))) + + // Кнопка возврата + buttons = append(buttons, bot.Row(bot.Button("« Назад", "back"))) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *SelectCreativeForPlacement) ensureJWT(b *bot.Bot) (string, error) { + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + b.Session.JWT = jwt + return jwt, nil +} + +func (s *SelectCreativeForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "page_info", data == "empty": + return + + case data == "skip": + // Пропускаем выбор креатива + if s.AddPlacementState != nil { + s.AddPlacementState.SelectedCreative = nil + b.SetState(s.AddPlacementState, bot.EditMessage) + } + + case data == "back": + if s.AddPlacementState != nil { + b.SetState(s.AddPlacementState, bot.EditMessage) + } + + case strings.HasPrefix(data, "select:"): + parts := strings.Split(data, ":") + if len(parts) == 2 { + creativeID := parts[1] + + // Получаем JWT + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + return + } + + // Загружаем полные данные креатива + creative, err := b.Backend.GetCreative(context.Background(), jwt, s.WorkspaceID, creativeID) + if err != nil { + log.Error().Err(err).Str("creative_id", creativeID).Msg("Failed to get creative") + return + } + + // Сохраняем выбранный креатив в состояние AddPlacement + if s.AddPlacementState != nil { + s.AddPlacementState.SelectedCreative = creative + b.SetState(s.AddPlacementState, bot.EditMessage) + } + } + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *SelectCreativeForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *SelectCreativeForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/telegram_bot/screens/select_project_for_placement.go b/telegram_bot/screens/select_project_for_placement.go new file mode 100644 index 0000000..1dd1d83 --- /dev/null +++ b/telegram_bot/screens/select_project_for_placement.go @@ -0,0 +1,164 @@ +package screens + +import ( + "context" + "fmt" + "strings" + + "example.com/m/bot" + "github.com/NicoNex/echotron/v3" + "github.com/rs/zerolog/log" +) + +const projectsForPlacementPerPage = 6 + +type SelectProjectForPlacement struct { + WorkspaceID string + CurrentPage int + AddPlacementState *AddPlacement +} + +func (s *SelectProjectForPlacement) Enter(b *bot.Bot, mode bot.RenderMode) { + jwt, err := s.ensureJWT(b) + if err != nil { + log.Error().Err(err).Msg("Failed to get JWT") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", bot.Keyboard()) + return + } + + s.renderProjects(b, mode, jwt) +} + +func (s *SelectProjectForPlacement) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) { + page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsForPlacementPerPage) + if err != nil { + log.Error().Err(err).Msg("Failed to get projects") + b.SendNew("❌ Не удалось загрузить проекты", bot.Keyboard()) + return + } + + text := "Выбор проекта\n\n" + + var buttons [][]echotron.InlineKeyboardButton + + if len(page.Items) == 0 { + text += "У вас пока нет проектов" + } else { + text += fmt.Sprintf("Выберите проект (стр. %d из %d):", s.CurrentPage+1, page.Pages) + + // Проекты по 2 в ряду + for i := 0; i < len(page.Items); i += 2 { + var row []echotron.InlineKeyboardButton + + // Первый проект в ряду + project := page.Items[i] + row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID))) + + // Второй проект в ряду (если есть) + if i+1 < len(page.Items) { + project = page.Items[i+1] + row = append(row, bot.Button(project.Title, fmt.Sprintf("select:%s", project.ID))) + } else { + row = append(row, bot.Button(" ", "empty")) + } + + buttons = append(buttons, row) + } + + // Кнопки пагинации + var navRow []echotron.InlineKeyboardButton + + if s.CurrentPage > 0 { + navRow = append(navRow, bot.Button("←", "prev")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") + navRow = append(navRow, pageButton) + + if s.CurrentPage+1 < page.Pages { + navRow = append(navRow, bot.Button("→", "next")) + } else { + navRow = append(navRow, bot.Button("·", "empty")) + } + + buttons = append(buttons, navRow) + } + + // Кнопка возврата + buttons = append(buttons, bot.Row(bot.Button("« Назад", "back"))) + + keyboard := bot.Keyboard(buttons...) + b.Render(text, keyboard, mode) +} + +func (s *SelectProjectForPlacement) ensureJWT(b *bot.Bot) (string, error) { + if b.Session.JWT != "" { + return b.Session.JWT, nil + } + + jwt, err := b.Backend.GetJWTByTelegramID(context.Background(), b.ChatID) + if err != nil { + return "", err + } + + b.Session.JWT = jwt + return jwt, nil +} + +func (s *SelectProjectForPlacement) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "prev": + if s.CurrentPage > 0 { + s.CurrentPage-- + } + s.Enter(b, bot.EditMessage) + + case data == "next": + s.CurrentPage++ + s.Enter(b, bot.EditMessage) + + case data == "page_info", data == "empty": + return + + case data == "back": + if s.AddPlacementState != nil { + b.SetState(s.AddPlacementState, bot.EditMessage) + } + + case strings.HasPrefix(data, "select:"): + parts := strings.Split(data, ":") + if len(parts) == 2 { + projectID := parts[1] + + // Загружаем полные данные проекта + project, err := b.Backend.GetProject(context.Background(), s.WorkspaceID, projectID) + if err != nil { + log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project") + return + } + + // Сохраняем выбранный проект в состояние AddPlacement + if s.AddPlacementState != nil { + s.AddPlacementState.SelectedProject = project + // Сбрасываем креатив при смене проекта + s.AddPlacementState.SelectedCreative = nil + b.SetState(s.AddPlacementState, bot.EditMessage) + } + } + + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *SelectProjectForPlacement) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *SelectProjectForPlacement) Handle(_ *bot.Bot, _ *echotron.Update) { return }