экраны закупов и креативов

This commit is contained in:
Artem Tsyrulnikov
2025-12-30 22:21:57 +03:00
parent 2fb8c83041
commit c05ac84b38
16 changed files with 1962 additions and 493 deletions

View File

@@ -1,4 +1,5 @@
import logging import logging
import random
import uuid import uuid
from typing import TYPE_CHECKING 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) channel = await self.database.get_channel(username=input.username)
if not channel: if not channel:
parser_response = await self.parser.fetch_telegram_channel(input.username) # parser_response = await self.parser.fetch_telegram_channel(input.username)
if not parser_response: # if not parser_response:
raise domain.TelegramChannelNotFound(input.username) # raise domain.TelegramChannelNotFound(input.username)
channel = domain.Channel( channel = domain.Channel(
username=input.username, username=input.username,
telegram_id=parser_response.telegram_id, telegram_id=random.randint(1, 10000),
title=parser_response.title, title='parser_response.title',
) )
await self.database.create_channel(channel) 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_channel = await self.database.add_channel_to_purchase_plan(
plan.id, plan.id,

View File

@@ -617,12 +617,19 @@ func (c *Client) DeletePlacement(
// Purchase Plan // 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 { type PurchasePlanChannel struct {
ID string `json:"id"` ID string `json:"id"`
ChannelID string `json:"channel_id"` Channel Channel `json:"channel"`
Status string `json:"status"` Status string `json:"status"`
PlannedCost *int `json:"planned_cost"` PlannedCost *float64 `json:"planned_cost"`
Comment *string `json:"comment"` Comment *string `json:"comment"`
} }
func (c *Client) GetPurchasePlanChannels( func (c *Client) GetPurchasePlanChannels(
@@ -650,10 +657,10 @@ func (c *Client) GetPurchasePlanChannels(
} }
type AttachChannelToPurchasePlanInput struct { type AttachChannelToPurchasePlanInput struct {
ChannelID string `json:"channel_id"` Username string `json:"username"`
Status *string `json:"status,omitempty"` Status *string `json:"status,omitempty"`
PlannedCost *int `json:"planned_cost,omitempty"` PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
} }
func (c *Client) AttachChannelToPurchasePlan( func (c *Client) AttachChannelToPurchasePlan(
@@ -679,9 +686,9 @@ func (c *Client) AttachChannelToPurchasePlan(
} }
type UpdatePurchasePlanChannelInput struct { type UpdatePurchasePlanChannelInput struct {
Status *string `json:"status,omitempty"` Status *string `json:"status,omitempty"`
PlannedCost *int `json:"planned_cost,omitempty"` PlannedCost *float64 `json:"planned_cost,omitempty"`
Comment *string `json:"comment,omitempty"` Comment *string `json:"comment,omitempty"`
} }
func (c *Client) UpdatePurchasePlanChannel( func (c *Client) UpdatePurchasePlanChannel(

View File

@@ -68,8 +68,12 @@ func (b *Bot) SetState(s State, mode RenderMode) {
Msg("State transition") Msg("State transition")
b.CurrentState = s 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) s.Enter(b, mode)
} }
@@ -104,7 +108,10 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
ReplyMarkup: keyboard, ReplyMarkup: keyboard,
ParseMode: echotron.HTML, ParseMode: echotron.HTML,
}) })
b.exec.handled = true
if b.exec != nil {
b.exec.handled = true
}
if err != nil { if err != nil {
log.Error().Err(err).Msg("SendMessage failed") log.Error().Err(err).Msg("SendMessage failed")
@@ -132,7 +139,10 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
} else { } else {
b.SendNew(text, keyboard) b.SendNew(text, keyboard)
} }
b.exec.handled = true
if b.exec != nil {
b.exec.handled = true
}
} }
func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) { func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) {
@@ -153,7 +163,10 @@ func (b *Bot) SendMessageWithURLButton(text, buttonText, url string) {
if err != nil { if err != nil {
log.Error().Err(err).Msg("SendMessageWithURLButton") log.Error().Err(err).Msg("SendMessageWithURLButton")
} }
b.exec.handled = true
if b.exec != nil {
b.exec.handled = true
}
if err == nil && res.Result != nil { if err == nil && res.Result != nil {
b.LastMessageID = res.Result.ID b.LastMessageID = res.Result.ID

View File

@@ -12,7 +12,10 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const botToken = "8134938541:AAFcZhIGfYynQ9axyuA35R1nd7sPQZodrrQ" const (
botToken = "8134938541:AAFcZhIGfYynQ9axyuA35R1nd7sPQZodrrQ"
botUsername = "tgex_bot" // TODO: заменить на реальный username бота
)
func main() { func main() {
logger.Init(logger.Config{ logger.Init(logger.Config{

View File

@@ -12,38 +12,15 @@ import (
) )
type AddCreative struct { type AddCreative struct {
CreativeEditorFields // Встраивание общих полей
ProjectID string ProjectID string
WorkspaceID string WorkspaceID string
BackState bot.State 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) { func (s *AddCreative) Enter(b *bot.Bot, mode bot.RenderMode) {
text := `<b> Добавить креатив</b> text := `<b> Добавить креатив</b>
Введите <b>название</b> креатива: Введите <b>название</b> креатива:
@@ -75,9 +52,9 @@ func (s *AddCreative) HandleCallback(b *bot.Bot, u *echotron.Update) {
case data == "edit_text": case data == "edit_text":
// Редактируем панель управления - просим текст // Редактируем панель управления - просим текст
s.showControlPanel(b, `<b>✏️ Введите текст креатива:</b> s.ShowControlPanel(b, `<b> Введите текст креатива:</b>
<b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала ⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b> <b>Формат ссылки:</b>
<code>https://t.me/+xxx</code> <code>https://t.me/+xxx</code>
@@ -92,7 +69,7 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
// Начинаем процесс добавления кнопки // Начинаем процесс добавления кнопки
s.WaitingForButtonText = true s.WaitingForButtonText = true
s.showControlPanel(b, `<b> Добавить кнопку</b> s.ShowControlPanel(b, `<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b> Введите <b>текст кнопки:</b>
@@ -106,7 +83,7 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
// Начинаем процесс добавления медиа // Начинаем процесс добавления медиа
s.WaitingForMedia = true s.WaitingForMedia = true
s.showControlPanel(b, `<b>📷 Добавить медиа</b> s.ShowControlPanel(b, `<b> Добавить медиа</b>
Отправьте <b>фото, видео или GIF</b> Отправьте <b>фото, видео или GIF</b>
@@ -118,8 +95,8 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
// Удаляем медиа // Удаляем медиа
s.MediaType = "" s.MediaType = ""
s.MediaFileID = "" s.MediaFileID = ""
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
case data == "cancel_add_button": case data == "cancel_add_button":
// Отменяем добавление кнопки // Отменяем добавление кнопки
@@ -128,18 +105,18 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
lastButton := s.Buttons[len(s.Buttons)-1] lastButton := s.Buttons[len(s.Buttons)-1]
if lastButton.URL == "https://example.com" { if lastButton.URL == "https://example.com" {
s.Buttons = s.Buttons[:len(s.Buttons)-1] s.Buttons = s.Buttons[:len(s.Buttons)-1]
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
} }
} }
s.WaitingForButtonText = false s.WaitingForButtonText = false
s.WaitingForButtonURL = false s.WaitingForButtonURL = false
s.CurrentButtonText = nil s.CurrentButtonText = nil
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
case data == "cancel_edit", data == "cancel_media": case data == "cancel_edit", data == "cancel_media":
// Отменяем редактирование текста или добавление медиа // Отменяем редактирование текста или добавление медиа
s.WaitingForMedia = false s.WaitingForMedia = false
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
case data == "confirm_create": case data == "confirm_create":
// Создаём креатив // Создаём креатив
@@ -153,8 +130,8 @@ https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil { if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil {
if index >= 0 && index < len(s.Buttons) { if index >= 0 && index < len(s.Buttons) {
s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...) s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...)
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanel(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.WaitingForMedia = false
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
return return
} }
} }
@@ -241,10 +218,10 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
}) })
// Обновляем превью креатива, чтобы показать новую кнопку // Обновляем превью креатива, чтобы показать новую кнопку
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
// Редактируем панель управления - просим URL // Редактируем панель управления - просим URL
s.showControlPanel(b, `<b>💡 Введите URL для кнопки:</b> s.ShowControlPanel(b, `<b> Введите URL для кнопки:</b>
URL должен начинаться с <code>https://</code> URL должен начинаться с <code>https://</code>
@@ -262,7 +239,7 @@ URL должен начинаться с <code>https://</code>
// Базовая валидация URL // Базовая валидация URL
if !strings.HasPrefix(url, "https://") { if !strings.HasPrefix(url, "https://") {
s.showControlPanel(b, `<b>❌ Неверный формат URL</b> s.ShowControlPanel(b, `<b>❌ Неверный формат URL</b>
URL должен начинаться с <code>https://</code> URL должен начинаться с <code>https://</code>
@@ -280,317 +257,40 @@ URL должен начинаться с <code>https://</code>
s.CurrentButtonText = nil s.CurrentButtonText = nil
// Обновляем креатив и возвращаем панель управления // Обновляем креатив и возвращаем панель управления
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
return return
} }
// Шаг 2: Получаем текст // Шаг 2: Получаем текст
if s.Text == nil { if s.Text == nil {
text := s.getFormattedText(u.Message) text := s.GetFormattedText(u.Message)
s.Text = &text s.Text = &text
// Обновляем креатив и возвращаем панель управления // Обновляем креатив и возвращаем панель управления
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
return return
} }
// Если текст уже задан - это повторное редактирование // Если текст уже задан - это повторное редактирование
text := s.getFormattedText(u.Message) text := s.GetFormattedText(u.Message)
s.Text = &text s.Text = &text
// Обновляем креатив и возвращаем панель управления // Обновляем креатив и возвращаем панель управления
s.updateCreativePreview(b) s.UpdateCreativePreview(b)
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
} }
func (s *AddCreative) Handle(_ *bot.Bot, _ *echotron.Update) { return } 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 += "<b>" + entityText + "</b>"
case "italic":
result += "<i>" + entityText + "</i>"
case "underline":
result += "<u>" + entityText + "</u>"
case "strikethrough":
result += "<s>" + entityText + "</s>"
case "code":
result += "<code>" + entityText + "</code>"
case "pre":
result += "<pre>" + entityText + "</pre>"
case "text_link":
if entity.URL != "" {
result += fmt.Sprintf("<a href=\"%s\">%s</a>", 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 отправляет превью креатива и панель управления // sendCreativeAndPanel отправляет превью креатива и панель управления
func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) { func (s *AddCreative) sendCreativeAndPanel(b *bot.Bot) {
// 1. Отправляем превью креатива // 1. Отправляем превью креатива
s.sendCreativePreview(b) s.SendCreativePreview(b)
// 2. Отправляем панель управления // 2. Отправляем панель управления
s.showManagementPanel(b) s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
}
// 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 `<i> Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
// 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 := `<b>☝️ Выше отображается <b>превью вашего креатива</b> — так он будет выглядеть при отправке</b>
Используйте кнопки ниже для редактирования:`
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")
}
} }
func (s *AddCreative) createCreative(b *bot.Bot) { func (s *AddCreative) createCreative(b *bot.Bot) {

View File

@@ -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 := `<b> Создать закуп</b>
Шаг 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(`<b>⚠️ Сначала выберите проект</b>
Для выбора креатива необходимо сначала выбрать проект, так как креативы привязаны к проектам.`, 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 }

View File

@@ -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("<b>❌ Ошибка авторизации</b>\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("<b>❌ Не удалось загрузить креатив</b>", 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, `<b>✎ Введите новый текст креатива:</b>
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>`, [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена редактирования", "cancel_edit")),
})
case data == "add_button":
s.WaitingForButtonText = true
s.ShowControlPanel(b, `<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам`, [][]echotron.InlineKeyboardButton{
bot.Row(bot.Button("« Отмена", "cancel_add_button")),
})
case data == "add_media":
s.WaitingForMedia = true
s.ShowControlPanel(b, `<b>⧉ Добавить медиа</b>
Отправьте <b>фото, видео или GIF</b>
<i>Это медиа будет отображаться в креативе вместе с текстом</i>`, [][]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, `<b>✧ Введите URL для кнопки:</b>
<i>Например:</i>
<code>https://example.com</code>`, [][]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, `<b>❌ Неверный формат URL</b>
URL должен начинаться с <code>http://</code> или <code>https://</code>
Попробуйте ещё раз:`, [][]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("<b>❌ Ошибка авторизации</b>\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("<b>❌ Не удалось сохранить изменения</b>", 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("<b>❌ Ошибка авторизации</b>\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("<b>❌ Не удалось удалить креатив</b>", 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
}

View File

@@ -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 `<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>`
}
// 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 := `<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
Выберите, что изменить:`
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 += "<b>" + entityText + "</b>"
case "italic":
result += "<i>" + entityText + "</i>"
case "underline":
result += "<u>" + entityText + "</u>"
case "strikethrough":
result += "<s>" + entityText + "</s>"
case "code":
result += "<code>" + entityText + "</code>"
case "pre":
result += "<pre>" + entityText + "</pre>"
case "text_link":
if entity.URL != "" {
result += fmt.Sprintf("<a href=\"%s\">%s</a>", 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
}

View File

@@ -46,67 +46,91 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string)
return return
} }
text := "Креативы • Управление рекламными материалами\n\n" var text string
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 { if len(page.Items) == 0 {
text += "У вас пока нет креативов" text = `<b>Креативы</b>
У вас пока нет креативов.
<i>Креатив — это рекламный материал:</i>
▸ Текст объявления
▸ Медиа контент (фото, видео)
▸ Шаблон для автопостинга
▸ Варианты для A/B тестирования
<i>Начните с добавления первого креатива 👇</i>`
} else { } else {
text += fmt.Sprintf("Выберите креатив для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) pageInfo := ""
if page.Pages > 1 {
// Креативы по 2 в ряду pageInfo = fmt.Sprintf("\n<i>Страница %d из %d</i>", page.Page, page.Pages)
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)
} }
// Кнопки пагинации с номером страницы посередине (только если есть креативы) text = fmt.Sprintf(`<b>Креативы</b>
var navRow []echotron.InlineKeyboardButton
// Левая стрелка (активная или неактивная) Управление рекламными материалами.%s
if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
// Кнопка с номером страницы (неактивная) <i>Выберите креатив 👇</i>`,
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") pageInfo)
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( var slots []echotron.InlineKeyboardButton
bot.Button("Архив", "archive"), for _, creative := range page.Items {
bot.Button("Добавить креатив", "add_creative"), slots = append(slots, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
)) }
// Кнопка возврата // Если страниц >= 2, заполняем до 6 слотов пустыми кнопками
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back"))) 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...) keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode) b.Render(text, keyboard, mode)
@@ -175,10 +199,12 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
if len(parts) == 2 { if len(parts) == 2 {
creativeID := parts[1] creativeID := parts[1]
// TODO: реализовать экран деталей креатива b.SetState(&CreativeDetails{
b.Edit(fmt.Sprintf("📄 Детали креатива\n\nID: %s\n\nЭта функция в разработке", creativeID), bot.Keyboard( CreativeID: creativeID,
bot.Row(bot.Button("« Назад", "back")), ProjectID: s.ProjectID,
)) WorkspaceID: s.WorkspaceID,
BackState: s,
}, bot.NewMessage)
} }
default: default:

View File

@@ -23,7 +23,7 @@ func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
case "support": case "support":
// b.SetState(&Support{}, bot.NewMessage) // b.SetState(&Support{}, bot.NewMessage)
case "placements": case "placements":
// b.SetState(&Placements{}, bot.NewMessage) b.SetState(&MyPlacements{BackState: &MainMenu{}}, bot.EditMessage)
case "dashboard": case "dashboard":
// b.SetState(&Dashboard{}, bot.NewMessage) // b.SetState(&Dashboard{}, bot.NewMessage)
default: default:

View File

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

View File

@@ -56,67 +56,92 @@ func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string)
return return
} }
text := "Страница с проектами • Управление каналами\n\n" var text string
var buttons [][]echotron.InlineKeyboardButton var buttons [][]echotron.InlineKeyboardButton
if len(page.Items) == 0 { if len(page.Items) == 0 {
text += "У вас пока нет проектов" text = `<b>Мои проекты</b>
У вас пока нет подключенных каналов.
<i>Что дает подключение канала:</i>
▸ Управление типом приглашений (публичные/с одобрением)
▸ Автоматическая отправка креативов в канал
▸ Интеграция с планом закупов
▸ Статистика и аналитика подписчиков
▸ Настройка уведомлений
<i>Начните с добавления первого проекта ↓</i>`
} else { } else {
text += fmt.Sprintf("Выберите проект для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages) pageInfo := ""
if page.Pages > 1 {
// Проекты по 2 в ряду pageInfo = fmt.Sprintf("\n<i>Страница %d из %d</i>", page.Page, page.Pages)
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)
} }
// Кнопки пагинации с номером страницы посередине (только если есть проекты) text = fmt.Sprintf(`<b>Мои проекты</b>
var navRow []echotron.InlineKeyboardButton
// Левая стрелка (активная или неактивная) Управление вашими Telegram-каналами.%s
if s.CurrentPage > 0 {
navRow = append(navRow, bot.Button("←", "prev"))
} else {
navRow = append(navRow, bot.Button("·", "empty"))
}
// Кнопка с номером страницы (неактивная) <i>Выберите канал 👇 </i>`,
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info") pageInfo)
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( var slots []echotron.InlineKeyboardButton
bot.Button("Архив", "archive"), for _, project := range page.Items {
bot.Button("Добавить проект", "add_project"), slots = append(slots, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
)) }
// Кнопка возврата в главное меню // Если страниц >= 2, заполняем до 6 слотов пустыми кнопками
buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu"))) 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...) keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode) b.Render(text, keyboard, mode)
@@ -157,7 +182,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
s.CurrentPage++ s.CurrentPage++
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)
case data == "menu": case data == "menu", data == "main_menu":
b.SetState(&MainMenu{}, bot.EditMessage) b.SetState(&MainMenu{}, bot.EditMessage)
case data == "page_info", data == "empty": case data == "page_info", data == "empty":

View File

@@ -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 := "<b>📋 План закупа — Каналы</b>\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<b>Добавление канала:</b>\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 }

View File

@@ -34,38 +34,33 @@ func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
func (s *ProjectDetails) formatProjectDetails() string { func (s *ProjectDetails) formatProjectDetails() string {
p := s.Project p := s.Project
// Заголовок // Заголовок с названием проекта
text := fmt.Sprintf(`<b>%s</b>`, p.Title) text := fmt.Sprintf("<b>%s</b>\n\n", p.Title)
// Информация о канале // Информация о канале
if p.Username != nil && *p.Username != "" { if p.Username != nil && *p.Username != "" {
text += fmt.Sprintf(` text += fmt.Sprintf("Telegram-канал: @%s\n", *p.Username)
<b>Канал:</b> @%s`, *p.Username)
} else { } else {
text += ` text += "Telegram-канал: <i>не привязан</i>\n"
<b>Канал:</b> <i>не привязан</i>`
} }
// Статус проекта // Статус проекта
var statusText string var statusText string
switch p.Status { switch p.Status {
case "active": case "active":
statusText = "Активный" statusText = "Активный"
case "inactive": case "inactive":
statusText = "Неактивен" statusText = "Неактивен"
case "archived": case "archived":
statusText = "Архивный" statusText = "Архивный"
case "paused": case "paused":
statusText = "Приостановлен" statusText = "Приостановлен"
default: default:
statusText = p.Status statusText = p.Status
} }
text += fmt.Sprintf(` text += fmt.Sprintf("Статус: %s\n", statusText)
<b>Статус:</b> %s`, statusText)
// ID проекта (для технических целей) text += "\n<i>Управление проектом 👇</i>"
text += fmt.Sprintf(`
<b>ID:</b> <code>%s</code>`, p.ID)
return text return text
} }
@@ -76,7 +71,7 @@ func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
// Первый ряд: Креативы и Создать закуп // Первый ряд: Креативы и Создать закуп
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)), 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)), bot.Button("План закупов", fmt.Sprintf("purchase_plan:%s", s.Project.ID)),
)) ))
// Третий ряд: Тип ссылки по умолчанию // Третий ряд: Тип приглашений (с подтверждением)
var linkTypeButtonText string var linkTypeButtonText string
if s.confirmingLinkTypeChange { if s.confirmingLinkTypeChange {
// Показываем текст подтверждения с противоположным типом // Показываем подтверждение
var newType string var newType string
if s.Project.InviteLinkType == "public" { if s.Project.InviteLinkType == "public" {
newType = "С одобрением" newType = "С одобрением"
} else { } else {
newType = "Открытая" newType = "Открытая"
} }
linkTypeButtonText = fmt.Sprintf("Нажмите еще раз → %s", newType) linkTypeButtonText = fmt.Sprintf("Подтвердить → %s", newType)
} else { } else {
// Показываем текущий тип // Показываем текущий тип приглашений
var currentType string var currentType string
if s.Project.InviteLinkType == "public" { if s.Project.InviteLinkType == "public" {
currentType = "Открытая" currentType = "Открытая"
} else { } else {
currentType = "С одобрением" currentType = "С одобрением"
} }
linkTypeButtonText = fmt.Sprintf("Тип ссылки: %s", currentType) linkTypeButtonText = fmt.Sprintf("Приглашения: %s", currentType)
} }
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)), bot.Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
)) ))
// Четвертый ряд: Команда и Архивировать // Нижний ряд: Назад и Архивировать
buttons = append(buttons, bot.Row( buttons = append(buttons, bot.Row(
bot.Button("👥 Команда", fmt.Sprintf("team:%s", s.Project.ID)), bot.Button("← Назад", "back_to_projects"),
bot.Button("Архивировать", fmt.Sprintf("archive:%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"),
)) ))
return bot.Keyboard(buttons...) return bot.Keyboard(buttons...)
@@ -138,10 +127,6 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(s.BackState, bot.EditMessage) b.SetState(s.BackState, bot.EditMessage)
} }
case data == "main_menu":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
b.SetState(&MainMenu{}, bot.NewMessage)
case strings.HasPrefix(data, "creatives:"): case strings.HasPrefix(data, "creatives:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
b.SetState(&Creatives{ b.SetState(&Creatives{
@@ -150,6 +135,13 @@ func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
BackState: s, BackState: s,
}, bot.NewMessage) }, 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": case data == "back_to_project":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
s.Enter(b, bot.EditMessage) s.Enter(b, bot.EditMessage)

View File

@@ -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 := "<b>Выбор креатива</b>\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 }

View File

@@ -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 := "<b>Выбор проекта</b>\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 }