package screens import ( "context" "fmt" "strings" "time" "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" "github.com/rs/zerolog/log" ) const projectsPerPage = 6 type MyProjects struct { CurrentPage int BackState bot.State OpenPlacements bool // Поля для управления фоновой горутиной polling cancel context.CancelFunc lastProjectCount int } func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { s.renderProjects(b, mode) s.startPolling(b) } func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode) { page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage) if err != nil { log.Error().Err(err).Msg("Failed to get projects") b.SendNew("❌ Не удалось загрузить проекты", Keyboard()) return } // Сохраняем количество проектов для polling s.lastProjectCount = page.Total var text string var buttons [][]echotron.InlineKeyboardButton headerTitle := "Мои проекты" emptyStateText := `У вас пока нет подключенных каналов. Что дает подключение канала: ▸ Управление типом приглашений (публичные/с одобрением) ▸ Автоматическая отправка креативов в канал ▸ Интеграция с планом закупов ▸ Статистика и аналитика подписчиков ▸ Настройка уведомлений Начните с добавления первого проекта` nonEmptyText := "Управление вашими Telegram-каналами.\n\nВыберите канал" if s.OpenPlacements { headerTitle = "Размещения" nonEmptyText = "Выберите проект для просмотра закупов" } if len(page.Items) == 0 { text = fmt.Sprintf(`%s %s`, headerTitle, emptyStateText) } else { text = fmt.Sprintf(`%s%s %s`, headerTitle, ui.FormatPageInfo(s.CurrentPage, page.Pages), nonEmptyText) } // Создаем слоты для проектов var slots []echotron.InlineKeyboardButton for _, project := range page.Items { slots = append(slots, Button(project.Title, fmt.Sprintf("project:%s", project.ID))) } // Если страниц >= 2, заполняем до 6 слотов пустыми кнопками if page.Pages >= 2 { for len(slots) < projectsPerPage { slots = append(slots, Button(" ", "empty")) } } // Разбиваем слоты по 2 в ряду for i := 0; i < len(slots); i += 2 { var row []echotron.InlineKeyboardButton row = append(row, slots[i]) // Второй слот в ряду (если есть) if i+1 < len(slots) { row = append(row, slots[i+1]) } else { // Если нечетное количество, добавляем пустую кнопку row = append(row, Button(" ", "empty")) } buttons = append(buttons, row) } // Ряд: Пагинация + Добавить (динамическая ширина) if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ CurrentPage: s.CurrentPage, TotalPages: page.Pages, MiddleButtons: []echotron.InlineKeyboardButton{Button("+ Добавить", "add_project")}, }); navRow != nil { buttons = append(buttons, navRow) } // Нижний ряд: Назад + Архив buttons = append(buttons, Row( Button("← Назад", "main_menu"), Button("≡ Архив", "archive"), )) keyboard := Keyboard(buttons...) b.Render(text, keyboard, mode) } func (s *MyProjects) 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 == "main_menu": b.SetState(&MainMenu{}, bot.EditMessage) case data == "archive": b.Edit("📦 Архив проектов\n\nЭта функция в разработке", Keyboard( Row(Button("← Назад", "back_to_projects")), )) case data == "add_project": b.SetState(&AddProject{BackState: &MyProjects{}}, bot.EditMessage) case data == "back_to_projects": s.Enter(b, bot.EditMessage) case strings.HasPrefix(data, "project:"): projectID, ok := strings.CutPrefix(data, "project:") if !ok || projectID == "" { return } project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, projectID) if err != nil { log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project") return } if s.OpenPlacements { b.SetState(&Placements{ ProjectID: project.ID, ProjectTitle: project.Title, BackState: &MyProjects{OpenPlacements: true}, }, bot.EditMessage) } else { b.SetState(&ProjectDetails{ Project: project, BackState: &MyProjects{}, }, bot.EditMessage) } default: s.Enter(b, bot.NewMessage) } return } func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *MyProjects) Exit() { if s.cancel != nil { log.Info().Msg("MyProjects: cancelling polling goroutine") s.cancel() } } func (s *MyProjects) startPolling(b *bot.Bot) { if s.cancel != nil { s.cancel() } ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) s.cancel = cancel go s.pollProjects(ctx, b) } func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot) { interval := 3 * time.Second timeToSlow := time.Now().Add(30 * time.Second) timer := time.NewTimer(interval) defer timer.Stop() for { select { case <-ctx.Done(): return case <-timer.C: if time.Now().After(timeToSlow) { interval = 10 * time.Second } page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage) if err == nil && page.Total != s.lastProjectCount { s.lastProjectCount = page.Total s.renderProjects(b, bot.EditMessage) } timer.Reset(interval) } } }