экраны закупов и креативов
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -617,11 +617,18 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -650,9 +657,9 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -680,7 +687,7 @@ 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,12 @@ func (b *Bot) SetState(s State, mode RenderMode) {
|
|||||||
Msg("State transition")
|
Msg("State transition")
|
||||||
|
|
||||||
b.CurrentState = s
|
b.CurrentState = s
|
||||||
|
|
||||||
|
if b.exec != nil {
|
||||||
b.exec.transitioned = true
|
b.exec.transitioned = true
|
||||||
b.exec.handled = 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,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if b.exec != nil {
|
||||||
b.exec.handled = true
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if b.exec != nil {
|
||||||
b.exec.handled = true
|
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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if b.exec != nil {
|
||||||
b.exec.handled = true
|
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
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
122
telegram_bot/screens/add_placement.go
Normal file
122
telegram_bot/screens/add_placement.go
Normal 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 }
|
||||||
391
telegram_bot/screens/creative_details.go
Normal file
391
telegram_bot/screens/creative_details.go
Normal 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
|
||||||
|
}
|
||||||
394
telegram_bot/screens/creative_editor.go
Normal file
394
telegram_bot/screens/creative_editor.go
Normal 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
|
||||||
|
}
|
||||||
@@ -46,68 +46,92 @@ 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>
|
||||||
} else {
|
|
||||||
text += fmt.Sprintf("Выберите креатив для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
|
|
||||||
|
|
||||||
// Креативы по 2 в ряду
|
У вас пока нет креативов.
|
||||||
for i := 0; i < len(page.Items); i += 2 {
|
|
||||||
|
<i>Креатив — это рекламный материал:</i>
|
||||||
|
▸ Текст объявления
|
||||||
|
▸ Медиа контент (фото, видео)
|
||||||
|
▸ Шаблон для автопостинга
|
||||||
|
▸ Варианты для A/B тестирования
|
||||||
|
|
||||||
|
<i>Начните с добавления первого креатива 👇</i>`
|
||||||
|
} else {
|
||||||
|
pageInfo := ""
|
||||||
|
if page.Pages > 1 {
|
||||||
|
pageInfo = fmt.Sprintf("\n<i>Страница %d из %d</i>", page.Page, page.Pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
text = fmt.Sprintf(`<b>Креативы</b>
|
||||||
|
|
||||||
|
Управление рекламными материалами.%s
|
||||||
|
|
||||||
|
<i>Выберите креатив 👇</i>`,
|
||||||
|
pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем слоты для креативов
|
||||||
|
var slots []echotron.InlineKeyboardButton
|
||||||
|
for _, creative := range page.Items {
|
||||||
|
slots = append(slots, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если страниц >= 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
|
var row []echotron.InlineKeyboardButton
|
||||||
|
row = append(row, slots[i])
|
||||||
|
|
||||||
// Первый креатив в ряду
|
// Второй слот в ряду (если есть)
|
||||||
creative := page.Items[i]
|
if i+1 < len(slots) {
|
||||||
row = append(row, bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
|
row = append(row, slots[i+1])
|
||||||
|
|
||||||
// Второй креатив в ряду (если есть)
|
|
||||||
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 {
|
} else {
|
||||||
// Добавляем пустую кнопку для выравнивания
|
// Если нечетное количество, добавляем пустую кнопку
|
||||||
row = append(row, bot.Button(" ", "empty"))
|
row = append(row, bot.Button(" ", "empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons = append(buttons, row)
|
buttons = append(buttons, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопки пагинации с номером страницы посередине (только если есть креативы)
|
// Ряд: Пагинация + Добавить
|
||||||
var navRow []echotron.InlineKeyboardButton
|
var navRow []echotron.InlineKeyboardButton
|
||||||
|
|
||||||
// Левая стрелка (активная или неактивная)
|
// Левая стрелка (или пустая кнопка)
|
||||||
if s.CurrentPage > 0 {
|
if s.CurrentPage > 0 {
|
||||||
navRow = append(navRow, bot.Button("←", "prev"))
|
navRow = append(navRow, bot.Button("←", "prev"))
|
||||||
} else {
|
} else {
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
navRow = append(navRow, bot.Button(" ", "empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопка с номером страницы (неактивная)
|
// Кнопка "Добавить креатив" всегда в центре
|
||||||
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
|
navRow = append(navRow, bot.Button("+ Добавить", "add_creative"))
|
||||||
navRow = append(navRow, pageButton)
|
|
||||||
|
|
||||||
// Правая стрелка (активная или неактивная)
|
// Правая стрелка (или пустая кнопка)
|
||||||
if s.CurrentPage+1 < page.Pages {
|
if s.CurrentPage+1 < page.Pages {
|
||||||
navRow = append(navRow, bot.Button("→", "next"))
|
navRow = append(navRow, bot.Button("→", "next"))
|
||||||
} else {
|
} else {
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
navRow = append(navRow, bot.Button(" ", "empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons = append(buttons, navRow)
|
buttons = append(buttons, navRow)
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки управления
|
// Нижний ряд: Назад + Архив
|
||||||
buttons = append(buttons, bot.Row(
|
buttons = append(buttons, bot.Row(
|
||||||
bot.Button("Архив", "archive"),
|
bot.Button("← Назад", "back"),
|
||||||
bot.Button("Добавить креатив", "add_creative"),
|
bot.Button("≡ Архив", "archive"),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Кнопка возврата
|
|
||||||
buttons = append(buttons, bot.Row(bot.Button("« Назад", "back")))
|
|
||||||
|
|
||||||
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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
221
telegram_bot/screens/my_placements.go
Normal file
221
telegram_bot/screens/my_placements.go
Normal 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 }
|
||||||
@@ -56,68 +56,93 @@ 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>
|
||||||
} else {
|
|
||||||
text += fmt.Sprintf("Выберите проект для управления (стр. %d из %d):", s.CurrentPage+1, page.Pages)
|
|
||||||
|
|
||||||
// Проекты по 2 в ряду
|
У вас пока нет подключенных каналов.
|
||||||
for i := 0; i < len(page.Items); i += 2 {
|
|
||||||
|
<i>Что дает подключение канала:</i>
|
||||||
|
▸ Управление типом приглашений (публичные/с одобрением)
|
||||||
|
▸ Автоматическая отправка креативов в канал
|
||||||
|
▸ Интеграция с планом закупов
|
||||||
|
▸ Статистика и аналитика подписчиков
|
||||||
|
▸ Настройка уведомлений
|
||||||
|
|
||||||
|
<i>Начните с добавления первого проекта ↓</i>`
|
||||||
|
} else {
|
||||||
|
pageInfo := ""
|
||||||
|
if page.Pages > 1 {
|
||||||
|
pageInfo = fmt.Sprintf("\n<i>Страница %d из %d</i>", page.Page, page.Pages)
|
||||||
|
}
|
||||||
|
|
||||||
|
text = fmt.Sprintf(`<b>Мои проекты</b>
|
||||||
|
|
||||||
|
Управление вашими Telegram-каналами.%s
|
||||||
|
|
||||||
|
<i>Выберите канал 👇 </i>`,
|
||||||
|
pageInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем слоты для проектов
|
||||||
|
var slots []echotron.InlineKeyboardButton
|
||||||
|
for _, project := range page.Items {
|
||||||
|
slots = append(slots, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если страниц >= 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
|
var row []echotron.InlineKeyboardButton
|
||||||
|
row = append(row, slots[i])
|
||||||
|
|
||||||
// Первый проект в ряду
|
// Второй слот в ряду (если есть)
|
||||||
project := page.Items[i]
|
if i+1 < len(slots) {
|
||||||
row = append(row, bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)))
|
row = append(row, slots[i+1])
|
||||||
|
|
||||||
// Второй проект в ряду (если есть)
|
|
||||||
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 {
|
} else {
|
||||||
// Добавляем пустую кнопку для выравнивания
|
// Если нечетное количество, добавляем пустую кнопку
|
||||||
row = append(row, bot.Button(" ", "empty"))
|
row = append(row, bot.Button(" ", "empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons = append(buttons, row)
|
buttons = append(buttons, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопки пагинации с номером страницы посередине (только если есть проекты)
|
// Ряд: Пагинация + Добавить
|
||||||
var navRow []echotron.InlineKeyboardButton
|
var navRow []echotron.InlineKeyboardButton
|
||||||
|
|
||||||
// Левая стрелка (активная или неактивная)
|
// Левая стрелка (или пустая кнопка)
|
||||||
if s.CurrentPage > 0 {
|
if s.CurrentPage > 0 {
|
||||||
navRow = append(navRow, bot.Button("←", "prev"))
|
navRow = append(navRow, bot.Button("←", "prev"))
|
||||||
} else {
|
} else {
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
navRow = append(navRow, bot.Button(" ", "empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кнопка с номером страницы (неактивная)
|
// Кнопка "Добавить проект" всегда в центре
|
||||||
pageButton := bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, page.Pages), "page_info")
|
navRow = append(navRow, bot.Button("+ Добавить", "add_project"))
|
||||||
navRow = append(navRow, pageButton)
|
|
||||||
|
|
||||||
// Правая стрелка (активная или неактивная)
|
// Правая стрелка (или пустая кнопка)
|
||||||
if s.CurrentPage+1 < page.Pages {
|
if s.CurrentPage+1 < page.Pages {
|
||||||
navRow = append(navRow, bot.Button("→", "next"))
|
navRow = append(navRow, bot.Button("→", "next"))
|
||||||
} else {
|
} else {
|
||||||
navRow = append(navRow, bot.Button("·", "empty"))
|
navRow = append(navRow, bot.Button(" ", "empty"))
|
||||||
}
|
}
|
||||||
|
|
||||||
buttons = append(buttons, navRow)
|
buttons = append(buttons, navRow)
|
||||||
}
|
|
||||||
|
|
||||||
// Кнопки управления
|
// Нижний ряд: Назад + Архив
|
||||||
buttons = append(buttons, bot.Row(
|
buttons = append(buttons, bot.Row(
|
||||||
bot.Button("Архив", "archive"),
|
bot.Button("← Назад", "menu"),
|
||||||
bot.Button("Добавить проект", "add_project"),
|
bot.Button("≡ Архив", "archive"),
|
||||||
))
|
))
|
||||||
|
|
||||||
// Кнопка возврата в главное меню
|
|
||||||
buttons = append(buttons, bot.Row(bot.Button("🏠 Главное меню", "menu")))
|
|
||||||
|
|
||||||
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":
|
||||||
|
|||||||
222
telegram_bot/screens/placement_purchase_plan.go
Normal file
222
telegram_bot/screens/placement_purchase_plan.go
Normal 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 }
|
||||||
@@ -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)
|
||||||
|
|||||||
188
telegram_bot/screens/select_creative_for_placement.go
Normal file
188
telegram_bot/screens/select_creative_for_placement.go
Normal 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 }
|
||||||
164
telegram_bot/screens/select_project_for_placement.go
Normal file
164
telegram_bot/screens/select_project_for_placement.go
Normal 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 }
|
||||||
Reference in New Issue
Block a user