package screens import ( "context" "fmt" "strings" "time" "example.com/m/bot" "example.com/m/ui" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) const projectsPerPage = 6 type MyProjects struct { CurrentPage int WorkspaceID string BackState bot.State OpenPurchases bool // Поля для управления фоновой горутиной polling cancel context.CancelFunc pollDone chan struct{} lastProjectCount int } func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { // JWT уже создан в Bot.Update(), просто берем из сессии jwt := b.Session.JWT if jwt == "" { log.Error().Msg("JWT is empty in session") b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", 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'ы", Keyboard()) return } if len(workspaces) == 0 { // Автоматически создаем workspace с именем пользователя username := "My Workspace" chatInfo, err := b.GetChat(b.ChatID) if err == nil && chatInfo.Result != nil { if chatInfo.Result.Username != "" { username = chatInfo.Result.Username } else if chatInfo.Result.FirstName != "" { username = chatInfo.Result.FirstName if chatInfo.Result.LastName != "" { username += " " + chatInfo.Result.LastName } } } log.Info().Str("username", username).Msg("Creating default workspace") workspace, err := b.Backend.CreateWorkspace(context.Background(), jwt, username) if err != nil { log.Error().Err(err).Msg("Failed to create workspace") b.SendNew("❌ Не удалось создать workspace", Keyboard()) return } s.WorkspaceID = workspace.ID log.Info().Str("workspace_id", s.WorkspaceID).Str("name", workspace.Name).Msg("Workspace created successfully") } else { // Используем первый workspace по умолчанию s.WorkspaceID = workspaces[0].ID } } s.renderProjects(b, mode, jwt) // Запускаем фоновую горутину для автообновления экрана s.startPolling(b, jwt) } func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) { page, err := b.Backend.GetProjects(context.Background(), jwt, s.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.OpenPurchases { 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("← Назад", "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 == "menu", 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{WorkspaceID: s.WorkspaceID}, }, bot.EditMessage) case data == "back_to_projects": s.Enter(b, bot.EditMessage) case strings.HasPrefix(data, "project:"): // Открытие деталей проекта parts := strings.Split(data, ":") if len(parts) == 2 { projectID := parts[1] // Получаем проект напрямую по ID 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 } if s.OpenPurchases { b.SetState(&Purchases{ WorkspaceID: s.WorkspaceID, ProjectID: project.ID, ProjectTitle: project.Title, BackState: &MyProjects{WorkspaceID: s.WorkspaceID, OpenPurchases: true}, }, bot.EditMessage) } else { b.SetState(&ProjectDetails{ Project: project, WorkspaceID: s.WorkspaceID, BackState: &MyProjects{WorkspaceID: s.WorkspaceID}, }, bot.EditMessage) } } 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 *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *MyProjects) Exit() { // Останавливаем polling горутину if s.cancel != nil { log.Info().Msg("MyProjects: cancelling polling goroutine") s.cancel() } // Ждем завершения горутины с таймаутом 100ms // Это не критично - горутина сама завершится при следующем tick if s.pollDone != nil { select { case <-s.pollDone: log.Info().Msg("MyProjects: polling goroutine finished") case <-time.After(100 * time.Millisecond): log.Info().Msg("MyProjects: polling cleanup timeout, goroutine will finish in background") } } } func (s *MyProjects) startPolling(b *bot.Bot, jwt string) { // Останавливаем предыдущую горутину если она есть if s.cancel != nil { s.cancel() if s.pollDone != nil { <-s.pollDone } } // Создаем новый контекст для горутины ctx, cancel := context.WithCancel(context.Background()) s.cancel = cancel s.pollDone = make(chan struct{}) go s.pollProjects(ctx, b, jwt) } func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot, jwt string) { defer close(s.pollDone) // Адаптивный интервал: первые 30 секунд проверяем часто (1 сек), // потом переходим на более редкий polling (3 сек) // Это улучшает UX когда пользователь только добавил проект intervals := []struct { duration time.Duration count int }{ {1 * time.Second, 30}, // Первые 30 секунд - каждую секунду {3 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно) } currentIntervalIdx := 0 tickCount := 0 ticker := time.NewTicker(intervals[0].duration) defer ticker.Stop() for { select { case <-ctx.Done(): log.Info().Msg("MyProjects polling: context cancelled, stopping") return case <-ticker.C: tickCount++ // Проверяем, нужно ли переключить интервал if currentIntervalIdx < len(intervals)-1 { if intervals[currentIntervalIdx].count > 0 && tickCount >= intervals[currentIntervalIdx].count { currentIntervalIdx++ tickCount = 0 ticker.Reset(intervals[currentIntervalIdx].duration) log.Info(). Dur("new_interval", intervals[currentIntervalIdx].duration). Msg("MyProjects polling: switching to slower interval") } } // Проверяем количество проектов page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage) if err != nil { log.Error().Err(err).Msg("MyProjects polling: failed to get projects") continue } // Если количество изменилось - перерендериваем if page.Total != s.lastProjectCount { log.Info(). Int("old_count", s.lastProjectCount). Int("new_count", page.Total). Msg("MyProjects polling: project count changed, re-rendering") s.lastProjectCount = page.Total s.renderProjects(b, bot.EditMessage, jwt) } } } }