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 "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 }