package screens import ( "context" "fmt" "regexp" "strings" "example.com/m/adapter/backend" "example.com/m/bot" "github.com/NicoNex/echotron/v3" "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 := "… › Создание › Каналы\n\n" text += "Создание закупа\n\n" text += "Шаг 2/2: Добавление каналов\n\n" var buttons [][]echotron.InlineKeyboardButton if len(s.Channels) == 0 { text += `Добавьте каналы для размещения рекламы Отправьте username канала (без @) Например: channel_name После добавления всех каналов нажмите Далее` } else { text += fmt.Sprintf("Добавлено каналов: %d\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 start := s.CurrentPage * channelsPerPage if start >= len(s.Channels) { s.CurrentPage = 0 start = 0 } end := start + channelsPerPage if end > len(s.Channels) { end = 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, bot.Button(buttonText, fmt.Sprintf("remove_channel:%d", i))) } if len(s.Channels) > channelsPerPage { for len(slots) < channelsPerPage { slots = append(slots, bot.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, bot.Button(" ", "empty")) } buttons = append(buttons, row) } pages := (len(s.Channels) + channelsPerPage - 1) / channelsPerPage if pages > 1 { var navRow []echotron.InlineKeyboardButton if s.CurrentPage > 0 { navRow = append(navRow, bot.Button("←", "prev_page")) } else { navRow = append(navRow, bot.Button("·", "empty")) } navRow = append(navRow, bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, pages), "page_info")) if s.CurrentPage+1 < pages { navRow = append(navRow, bot.Button("→", "next_page")) } else { navRow = append(navRow, bot.Button("·", "empty")) } 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, bot.Row( bot.Button("← Назад", "back"), bot.Button("→ Далее", "next"), )) } else { buttons = append(buttons, bot.Row( bot.Button("← Назад", "back"), )) } keyboard := bot.Keyboard(buttons...) b.Render(text, keyboard, mode) } func (s *SelectChannelsForPurchase) 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 *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": if len(s.Channels) == 0 { b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard( bot.Row(bot.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_page": if s.CurrentPage > 0 { s.CurrentPage-- } s.Enter(b, bot.EditMessage) case "next_page": 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 := (len(s.Channels) + channelsPerPage - 1) / 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.CreatePurchaseChannelInput for _, ch := range s.Channels { apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{ Username: ch.Username, PlannedCost: ch.PlannedCost, Comment: ch.Comment, }) } input := backend.CreatePurchaseInput{ CreativeID: s.CreativeID, Channels: apiChannels, } purchase, err := b.Backend.CreatePurchase( context.Background(), jwt, s.WorkspaceID, s.ProjectID, input, ) if err != nil { log.Error().Err(err).Msg("Failed to create purchase") b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", bot.Keyboard( bot.Row(bot.Button("← Назад", "back_to_select")), )) return } // Показываем успешное создание successText := fmt.Sprintf(`… › Создание › Готово Закуп созданID: %s ▪ Креатив: %s ▪ Каналов: %d Закуп создан и готов к работе`, purchase.ID, purchase.CreativeID, len(purchase.Channels)) b.Edit(successText, bot.Keyboard( bot.Row(bot.Button("● Готово", "done")), )) } 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) 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) } } }