387 lines
9.8 KiB
Go
387 lines
9.8 KiB
Go
package screens
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
|
||
"github.com/NicoNex/echotron/v3"
|
||
"github.com/TelegramExchange/tgex-backend/tg_bot/backend"
|
||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
||
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
|
||
"github.com/rs/zerolog/log"
|
||
)
|
||
|
||
type PurchaseChannelInput struct {
|
||
Username string
|
||
PlannedCost *float64
|
||
Comment *string
|
||
}
|
||
|
||
type SelectChannelsForPurchase struct {
|
||
WorkspaceID string
|
||
ProjectID string
|
||
ProjectTitle string
|
||
CreativeID string
|
||
CreativeTitle string
|
||
Channels []PurchaseChannelInput
|
||
CurrentPage int
|
||
InvalidUsernames []string
|
||
BackState bot.State
|
||
}
|
||
|
||
var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
||
|
||
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||
s.renderChannelSelection(b, mode)
|
||
}
|
||
|
||
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
|
||
text := "<b>Создание закупа</b>\n\n"
|
||
text += "<b>Шаг 2/2:</b> Добавление каналов\n\n"
|
||
|
||
var buttons [][]echotron.InlineKeyboardButton
|
||
|
||
if len(s.Channels) == 0 {
|
||
text += `Добавьте каналы для размещения рекламы
|
||
|
||
Отправьте username канала (без @)
|
||
Например: <code>channel_name</code>
|
||
|
||
После добавления всех каналов нажмите <b>Далее</b>`
|
||
} else {
|
||
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
|
||
|
||
for i, ch := range s.Channels {
|
||
channelText := fmt.Sprintf(" %d. @%s", i+1, ch.Username)
|
||
if ch.PlannedCost != nil {
|
||
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
|
||
}
|
||
text += channelText + "\n"
|
||
}
|
||
text += "\n"
|
||
|
||
const channelsPerPage = 6
|
||
if s.CurrentPage*channelsPerPage >= len(s.Channels) {
|
||
s.CurrentPage = 0
|
||
}
|
||
start, end := ui.GetPageBounds(s.CurrentPage, channelsPerPage, len(s.Channels))
|
||
|
||
var slots []echotron.InlineKeyboardButton
|
||
for i := start; i < end; i++ {
|
||
ch := s.Channels[i]
|
||
buttonText := fmt.Sprintf("@%s", ch.Username)
|
||
if ch.PlannedCost != nil {
|
||
buttonText = fmt.Sprintf("@%s — %.0f₽", ch.Username, *ch.PlannedCost)
|
||
}
|
||
slots = append(slots, Button(buttonText, fmt.Sprintf("remove_channel:%d", i)))
|
||
}
|
||
|
||
if len(s.Channels) > channelsPerPage {
|
||
for len(slots) < channelsPerPage {
|
||
slots = append(slots, Button(" ", "empty"))
|
||
}
|
||
}
|
||
|
||
for i := 0; i < len(slots); i += 2 {
|
||
row := []echotron.InlineKeyboardButton{slots[i]}
|
||
if i+1 < len(slots) {
|
||
row = append(row, slots[i+1])
|
||
} else {
|
||
row = append(row, Button(" ", "empty"))
|
||
}
|
||
buttons = append(buttons, row)
|
||
}
|
||
|
||
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
|
||
navRow := ui.BuildNavigationRow(ui.PaginationConfig{
|
||
CurrentPage: s.CurrentPage,
|
||
TotalPages: pages,
|
||
})
|
||
if navRow != nil {
|
||
buttons = append(buttons, navRow)
|
||
}
|
||
|
||
text += "\nДобавьте еще каналы или создайте закуп"
|
||
|
||
// Кнопка создания закупа (доступна только если есть каналы)
|
||
}
|
||
|
||
if len(s.InvalidUsernames) > 0 {
|
||
text += fmt.Sprintf("\n\n⚠️ Пропущены: %s", strings.Join(s.InvalidUsernames, ", "))
|
||
s.InvalidUsernames = nil
|
||
}
|
||
|
||
// Кнопки навигации
|
||
if len(s.Channels) > 0 {
|
||
buttons = append(buttons, Row(
|
||
Button("← Назад", "back"),
|
||
Button("→ Далее", "next_step"),
|
||
))
|
||
} else {
|
||
buttons = append(buttons, Row(
|
||
Button("← Назад", "back"),
|
||
))
|
||
}
|
||
|
||
keyboard := Keyboard(buttons...)
|
||
b.Render(text, keyboard, mode)
|
||
}
|
||
|
||
func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||
return
|
||
}
|
||
|
||
data := u.CallbackQuery.Data
|
||
|
||
switch data {
|
||
case "back":
|
||
// Возвращаемся к выбору креатива
|
||
b.SetState(&AddPurchase{
|
||
WorkspaceID: s.WorkspaceID,
|
||
ProjectID: s.ProjectID,
|
||
ProjectTitle: s.ProjectTitle,
|
||
CreativeID: s.CreativeID,
|
||
CreativeTitle: s.CreativeTitle,
|
||
ActivePicker: "",
|
||
BackState: s.BackState,
|
||
}, bot.EditMessage)
|
||
|
||
case "next_step":
|
||
if len(s.Channels) == 0 {
|
||
b.Edit("❌ Добавьте хотя бы один канал", Keyboard(
|
||
Row(Button("← Назад", "back_to_select")),
|
||
))
|
||
return
|
||
}
|
||
|
||
// Проверяем существование каналов через API
|
||
jwt := b.Session.JWT
|
||
if jwt == "" {
|
||
log.Error().Msg("JWT is empty in session")
|
||
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
|
||
return
|
||
}
|
||
|
||
notFoundChannels := s.validateChannels(b, jwt)
|
||
if len(notFoundChannels) > 0 {
|
||
text := "❌ Следующие каналы не найдены:\n\n"
|
||
for _, username := range notFoundChannels {
|
||
text += fmt.Sprintf("• @%s\n", username)
|
||
}
|
||
text += "\nУдалите их из списка и попробуйте снова"
|
||
|
||
b.Edit(text, Keyboard(
|
||
Row(Button("← Назад", "back_to_select")),
|
||
))
|
||
return
|
||
}
|
||
|
||
b.SetState(&PurchaseOptionalDetails{
|
||
WorkspaceID: s.WorkspaceID,
|
||
ProjectID: s.ProjectID,
|
||
ProjectTitle: s.ProjectTitle,
|
||
CreativeID: s.CreativeID,
|
||
CreativeTitle: s.CreativeTitle,
|
||
Channels: s.Channels,
|
||
BackState: s.BackState,
|
||
}, bot.EditMessage)
|
||
|
||
case "back_to_select":
|
||
s.Enter(b, bot.EditMessage)
|
||
|
||
case "prev":
|
||
if s.CurrentPage > 0 {
|
||
s.CurrentPage--
|
||
}
|
||
s.Enter(b, bot.EditMessage)
|
||
|
||
case "next":
|
||
s.CurrentPage++
|
||
s.Enter(b, bot.EditMessage)
|
||
|
||
default:
|
||
if len(data) > 15 && data[:15] == "remove_channel:" {
|
||
var index int
|
||
if _, err := fmt.Sscanf(data[15:], "%d", &index); err == nil {
|
||
if index >= 0 && index < len(s.Channels) {
|
||
s.Channels = append(s.Channels[:index], s.Channels[index+1:]...)
|
||
if s.CurrentPage > 0 {
|
||
channelsPerPage := 6
|
||
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
|
||
if s.CurrentPage >= pages {
|
||
s.CurrentPage = pages - 1
|
||
}
|
||
}
|
||
s.Enter(b, bot.EditMessage)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
s.Enter(b, bot.NewMessage)
|
||
}
|
||
}
|
||
|
||
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
|
||
// Преобразуем каналы в формат API
|
||
var apiChannels []backend.CreatePlacementChannelInput
|
||
for _, ch := range s.Channels {
|
||
var details *backend.PlacementDetails
|
||
if ch.PlannedCost != nil {
|
||
cost := backend.CostInfo{
|
||
Type: "fixed",
|
||
Value: *ch.PlannedCost,
|
||
}
|
||
details = &backend.PlacementDetails{
|
||
Cost: &cost,
|
||
}
|
||
}
|
||
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
|
||
Username: ch.Username,
|
||
Comment: ch.Comment,
|
||
Details: details,
|
||
})
|
||
}
|
||
|
||
input := backend.CreatePlacementsInput{
|
||
CreativeID: s.CreativeID,
|
||
Channels: apiChannels,
|
||
}
|
||
|
||
placements, err := b.Backend.CreatePlacements(
|
||
context.Background(),
|
||
jwt,
|
||
s.WorkspaceID,
|
||
s.ProjectID,
|
||
input,
|
||
)
|
||
|
||
if err != nil {
|
||
log.Error().Err(err).Msg("Failed to create placements")
|
||
b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
|
||
Row(Button("← Назад", "back_to_select")),
|
||
))
|
||
return
|
||
}
|
||
|
||
if len(placements.Placements) == 0 {
|
||
b.Edit("❌ Размещения не созданы", Keyboard(
|
||
Row(Button("← Назад", "back_to_select")),
|
||
))
|
||
return
|
||
}
|
||
|
||
b.SetState(&PlacementDetails{
|
||
WorkspaceID: s.WorkspaceID,
|
||
ProjectID: s.ProjectID,
|
||
PlacementID: placements.Placements[0].ID,
|
||
BackState: s.BackState,
|
||
}, bot.EditMessage)
|
||
}
|
||
|
||
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
|
||
if u.Message == nil || u.Message.Text == "" {
|
||
return
|
||
}
|
||
|
||
raw := strings.TrimSpace(u.Message.Text)
|
||
if raw == "" {
|
||
return
|
||
}
|
||
|
||
tokens := strings.FieldsFunc(raw, func(r rune) bool {
|
||
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
|
||
})
|
||
|
||
added := 0
|
||
var invalid []string
|
||
for _, token := range tokens {
|
||
username := strings.TrimSpace(token)
|
||
username = strings.TrimPrefix(username, "@")
|
||
username = strings.Trim(username, "@")
|
||
username = strings.Trim(username, ",;")
|
||
|
||
if username == "" {
|
||
continue
|
||
}
|
||
if !channelUsernameRe.MatchString(username) {
|
||
invalid = append(invalid, username)
|
||
continue
|
||
}
|
||
|
||
exists := false
|
||
for _, ch := range s.Channels {
|
||
if ch.Username == username {
|
||
exists = true
|
||
break
|
||
}
|
||
}
|
||
if exists {
|
||
continue
|
||
}
|
||
|
||
s.Channels = append(s.Channels, PurchaseChannelInput{
|
||
Username: username,
|
||
})
|
||
added++
|
||
}
|
||
|
||
if added == 0 {
|
||
if len(invalid) == 0 {
|
||
return
|
||
}
|
||
}
|
||
|
||
s.InvalidUsernames = invalid
|
||
|
||
// Обновляем экран
|
||
s.Enter(b, bot.NewMessage)
|
||
}
|
||
|
||
func (s *SelectChannelsForPurchase) validateChannels(b *bot.Bot, jwt string) []string {
|
||
var notFound []string
|
||
|
||
for _, ch := range s.Channels {
|
||
channels, err := b.Backend.SearchChannels(
|
||
context.Background(),
|
||
jwt,
|
||
ch.Username,
|
||
)
|
||
|
||
if err != nil {
|
||
log.Error().Err(err).Str("username", ch.Username).Msg("Failed to search channel")
|
||
notFound = append(notFound, ch.Username)
|
||
continue
|
||
}
|
||
|
||
// Проверяем что нашли хотя бы один канал с точным совпадением username
|
||
found := false
|
||
for _, channel := range channels {
|
||
if channel.Username != nil && *channel.Username == ch.Username {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
notFound = append(notFound, ch.Username)
|
||
}
|
||
}
|
||
|
||
return notFound
|
||
}
|
||
|
||
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
|
||
// Обрабатываем callback после успешного создания
|
||
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
|
||
// Возвращаемся к списку закупов
|
||
if s.BackState != nil {
|
||
b.SetState(s.BackState, bot.EditMessage)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *SelectChannelsForPurchase) Exit() {}
|