package screens import ( "context" "fmt" "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 { ChannelID string Username string Title string InviteLink string PlannedCost *float64 Comment *string } type SelectChannelsForPurchase struct { ProjectID string ProjectTitle string ProjectTelegramID int64 ProjectUsername string ProjectStatus string CreativeID string CreativeTitle string Channels []PurchaseChannelInput CurrentPage int InvalidUsernames []string Duplicates []string // дубликаты каналов ParsingErrors []ParseError // ошибки парсинга с предложениями BackState bot.State } // ParseError представляет ошибку парсинга с предложением исправления type ParseError struct { Input string Suggestion string } 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 += "Шаг 2/2: Добавление каналов\n\n" var buttons [][]echotron.InlineKeyboardButton if len(s.Channels) == 0 { text += `Добавьте каналы для размещения рекламы Отправьте username (без @) или invite link приватного канала Например: channel_name или https://t.me/+abcdef После добавления всех каналов нажмите Далее` } else { text += fmt.Sprintf("Добавлено каналов: %d\n\n", len(s.Channels)) for i, ch := range s.Channels { channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch)) 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 := channelLabel(ch) if ch.PlannedCost != nil { buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *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.Duplicates) > 0 { text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", ")) s.Duplicates = nil } // Показываем ошибки с предложениями if len(s.ParsingErrors) > 0 { text += "\n\n❌ Ошибки форматирования:\n" for i, err := range s.ParsingErrors { if i < 3 { // Показываем максимум 3 ошибки if err.Suggestion != "" { text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion) } else { text += fmt.Sprintf("• %s\n", err.Input) } } } if len(s.ParsingErrors) > 3 { text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3) } s.ParsingErrors = 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{ ProjectID: s.ProjectID, ProjectTitle: s.ProjectTitle, ProjectTelegramID: s.ProjectTelegramID, ProjectUsername: s.ProjectUsername, ProjectStatus: s.ProjectStatus, 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 } // Проверяем, что все каналы резолвлены (имеют ChannelID) var unresolved []string for _, ch := range s.Channels { if ch.ChannelID == "" { label := channelLabel(ch) unresolved = append(unresolved, label) } } if len(unresolved) > 0 { text := "❌ Некоторые каналы не были найдены:\n\n" for _, label := range unresolved { text += fmt.Sprintf("• %s\n", label) } text += "\nУдалите их из списка и попробуйте снова" b.Edit(text, Keyboard( Row(Button("← Назад", "back_to_select")), )) return } // Каналы уже резолвлены в HandleMessage, переходим к следующему шагу b.SetState(&PurchaseOptionalDetails{ 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{ ChannelID: ch.ChannelID, Comment: ch.Comment, Details: details, }) } input := backend.CreatePlacementsInput{ CreativeID: &s.CreativeID, Channels: apiChannels, } placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.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{ 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 } // Проверяем JWT jwt := b.Session.JWT if jwt == "" { log.Error().Msg("JWT is empty in session") b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard()) return } // Разбиваем ввод на токены tokens := strings.FieldsFunc(raw, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';' }) added := 0 s.Duplicates = nil s.InvalidUsernames = nil s.ParsingErrors = nil tokensLoop: for _, token := range tokens { entry := strings.TrimSpace(token) entry = strings.Trim(entry, ",;") if entry == "" { continue } // Используем новый парсер parsed := ParseChannelInput(entry) if !parsed.Valid { // Пробуем предложить исправление if suggestion, ok := SuggestFix(entry); ok { s.ParsingErrors = append(s.ParsingErrors, ParseError{ Input: entry, Suggestion: suggestion, }) } else { s.InvalidUsernames = append(s.InvalidUsernames, entry) } continue } // Проверяем дубликаты по username/invite link if IsDuplicate(parsed, s.Channels) { label := FormatChannelLabel(parsed) s.Duplicates = append(s.Duplicates, label) continue } // Сразу резолвим канал через бэкенд channel, err := s.resolveSingleChannel(b, jwt, parsed) if err != nil { // Канал не найден или ошибка s.InvalidUsernames = append(s.InvalidUsernames, entry) log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel") continue } // Проверяем дубликаты по ID канала (после резолва) for _, ch := range s.Channels { if ch.ChannelID != "" && ch.ChannelID == channel.ID { var title, username string if channel.Title != nil { title = *channel.Title } if channel.Username != nil { username = *channel.Username } label := channelLabelByID(&channel.ID, &title, &username) s.Duplicates = append(s.Duplicates, label) continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего! } // Дополнительная проверка по username (case-insensitive) // На случай, если канал еще не резолвлен или ID отличается if channel.Username != nil && ch.Username != "" { if strings.EqualFold(ch.Username, *channel.Username) { var title string if channel.Title != nil { title = *channel.Title } label := channelLabelByID(&channel.ID, &title, channel.Username) s.Duplicates = append(s.Duplicates, label) continue tokensLoop } } } // Добавляем канал с полными данными var title string if channel.Title != nil { title = *channel.Title } s.Channels = append(s.Channels, PurchaseChannelInput{ ChannelID: channel.ID, Username: parsed.Username, Title: title, InviteLink: parsed.InviteLink, }) added++ } // Если ничего не добавлено и нет ошибок - не обновляем экран if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 { return } // Обновляем экран s.Enter(b, bot.NewMessage) } // resolveSingleChannel резолвит один канал через бэкенд func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) { var input backend.CreateChannelInput if parsed.InviteLink != "" { link := parsed.InviteLink input = backend.CreateChannelInput{InviteLink: &link} } else { username := parsed.Username input = backend.CreateChannelInput{Username: &username} } resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{ Channels: []backend.CreateChannelInput{input}, }) if err != nil { return nil, err } if len(resp.Results) == 0 { return nil, fmt.Errorf("no response from backend") } result := resp.Results[0] if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" { errMsg := "channel not found" if result.Error != nil { errMsg = *result.Error } return nil, fmt.Errorf(errMsg) } // Если пришел username из ответа, обновляем его if result.Channel.Username != nil && *result.Channel.Username != "" { parsed.Username = *result.Channel.Username } return result.Channel, nil } // channelLabelByID формирует метку канала по ID func channelLabelByID(id, title, username *string) string { if title != nil && *title != "" { return *title } if username != nil && *username != "" { return "@" + *username } if id != nil && *id != "" { return *id } return "канал" } func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string { inputs := make([]backend.CreateChannelInput, 0, len(s.Channels)) for _, ch := range s.Channels { if ch.InviteLink != "" { link := ch.InviteLink inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link}) } else { username := ch.Username inputs = append(inputs, backend.CreateChannelInput{Username: &username}) } } resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{ Channels: inputs, }) if err != nil { log.Error().Err(err).Msg("Failed to create channels") return []string{"ошибка сервера"} } var failed []string for _, result := range resp.Results { if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" { if result.Index >= 0 && result.Index < len(s.Channels) { failed = append(failed, channelLabel(s.Channels[result.Index])) } continue } if result.Index < 0 || result.Index >= len(s.Channels) { continue } ch := &s.Channels[result.Index] ch.ChannelID = result.Channel.ID if result.Channel.Username != nil { ch.Username = *result.Channel.Username } if result.Channel.Title != nil { ch.Title = *result.Channel.Title } } return failed } 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() {} func channelLabel(ch PurchaseChannelInput) string { if ch.Username != "" { return "@" + ch.Username } if ch.Title != "" { return ch.Title } if ch.InviteLink != "" { return formatInviteLabel(ch.InviteLink) } if ch.ChannelID != "" { return ch.ChannelID } return "канал" } func channelKey(ch PurchaseChannelInput) string { if ch.ChannelID != "" { return ch.ChannelID } if ch.Username != "" { return ch.Username } if ch.InviteLink != "" { return ch.InviteLink } return "" } func channelLabelByKey(channels []PurchaseChannelInput, key string) string { for _, ch := range channels { if channelKey(ch) == key { return channelLabel(ch) } } return key } func isInviteLink(value string) bool { value = strings.TrimSpace(value) if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") { return true } if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") { return true } return false } func formatInviteLabel(inviteLink string) string { link := strings.TrimSpace(inviteLink) if link == "" { return "приватный канал" } code := link if strings.HasPrefix(code, "tg://") || strings.HasPrefix(code, "tg:") { if idx := strings.Index(code, "invite="); idx != -1 { code = code[idx+len("invite="):] if end := strings.IndexAny(code, "&?#"); end != -1 { code = code[:end] } } } else { code = strings.TrimPrefix(code, "https://") code = strings.TrimPrefix(code, "http://") code = strings.TrimPrefix(code, "t.me/") code = strings.TrimPrefix(code, "telegram.me/") code = strings.TrimPrefix(code, "joinchat/") code = strings.TrimPrefix(code, "+") if idx := strings.LastIndex(code, "/"); idx != -1 { code = code[idx+1:] } if end := strings.IndexAny(code, "?#"); end != -1 { code = code[:end] } } code = strings.Trim(code, "/+ ") if code == "" { return "приватный канал" } if len(code) > 10 { code = code[:6] + "..." + code[len(code)-2:] } return "приватный: " + code }