diff --git a/src/controller/http_v1/internal.py b/src/controller/http_v1/internal.py index cece2f7..e37f270 100644 --- a/src/controller/http_v1/internal.py +++ b/src/controller/http_v1/internal.py @@ -6,19 +6,13 @@ from src import deps, dto internal_router = APIRouter(prefix='/internal', tags=['internal']) - - class CreateLoginTokenResponse(BaseModel): token: str @internal_router.post('/auth/login-token') async def create_login_token(input: dto.CreateLoginTokenRequest) -> str: - token = await deps.get_usecase().create_telegram_login_token( - telegram_id=input.telegram_id, - username=input.username, - ) - return token + return await deps.get_usecase().create_telegram_login_token(telegram_id=input.telegram_id) class ValidateTokenRequest(BaseModel): @@ -27,7 +21,6 @@ class ValidateTokenRequest(BaseModel): username: str | None = None - @internal_router.get('/auth/jwt') async def get_jwt_by_telegram_id( telegram_id: int, diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 9caa015..591dfa7 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -98,19 +98,19 @@ from .analytics import ( GetChannelAnalyticsOutput, GetCreativesAnalyticsInput, GetCreativesAnalyticsOutput, + GetOverviewAnalyticsInput, + GetOverviewAnalyticsOutput, GetPlacementsAnalyticsInput, GetPlacementsAnalyticsOutput, - GetOverviewAnalyticsInput, GetProjectsAnalyticsInput, GetProjectsAnalyticsOutput, GetSpendingAnalyticsInput, GetSpendingAnalyticsOutput, - GetOverviewAnalyticsOutput, NumberWithDelta, - PlacementAnalyticsOutput, - OverviewDailyPoint, OverviewChannelPerformance, + OverviewDailyPoint, OverviewProjectSpending, + PlacementAnalyticsOutput, ProjectAnalyticsPeriod, ProjectMetrics, ProjectMetricsData, @@ -189,4 +189,3 @@ from .workspace import ( class CreateLoginTokenRequest(BaseModel): telegram_id: int - username: str | None diff --git a/src/usecase/auth/create_telegram_login_token.py b/src/usecase/auth/create_telegram_login_token.py index 51a5955..8f3c1f1 100644 --- a/src/usecase/auth/create_telegram_login_token.py +++ b/src/usecase/auth/create_telegram_login_token.py @@ -10,14 +10,11 @@ if typing.TYPE_CHECKING: from .. import Usecase -async def create_telegram_login_token(self: 'Usecase', telegram_id: int, username: str | None) -> str: +async def create_telegram_login_token(self: 'Usecase', telegram_id: int) -> str: telegram_user = await self.database.get_telegram_user(telegram_id=telegram_id) if not telegram_user: - telegram_user = domain.TelegramUser(telegram_id=telegram_id, username=username) + telegram_user = domain.TelegramUser(telegram_id=telegram_id) await self.database.create_telegram_user(telegram_user) - elif username and telegram_user.username != username: - telegram_user.username = username - await self.database.update_telegram_user(telegram_user) user = await self.database.get_user(telegram_id=telegram_id) if not user: diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go index cf17fa7..1062469 100644 --- a/telegram_bot/adapter/backend/backend_client.go +++ b/telegram_bot/adapter/backend/backend_client.go @@ -89,31 +89,19 @@ func (c *Client) do(ctx context.Context, method string, path string, in any, out return nil } -func (c *Client) GetLoginURL(token string) string { +func (c *Client) LoginURL(token string) string { return c.loginURL + token } -func (c *Client) CreateLoginToken( - ctx context.Context, - telegramID int64, - username *string, -) (string, error) { +func (c *Client) CreateLoginToken(ctx context.Context, telegramID int64) (string, error) { req := struct { - TelegramID int64 `json:"telegram_id"` - Username *string `json:"username"` + TelegramID int64 `json:"telegram_id"` }{ TelegramID: telegramID, - Username: username, } var token string - err := c.do( - ctx, - http.MethodPost, - "api/v1/internal/auth/login-token", - req, - &token, - ) + err := c.do(ctx, http.MethodPost, "api/v1/internal/auth/login-token", req, &token) return token, err } @@ -313,6 +301,28 @@ func (c *Client) ForwardTelegramUpdate( ) } +func (c *Client) SearchChannels( + ctx context.Context, + jwt string, + username string, +) ([]Channel, error) { + path := fmt.Sprintf("api/v1/channels?username=%s", username) + + var response struct { + Items []Channel `json:"items"` + } + err := c.do( + ctx, + http.MethodGet, + path, + nil, + &response, + withBearer(jwt), + ) + + return response.Items, err +} + func (c *Client) AttachChannelToWorkspace( ctx context.Context, channelID string, diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go index 175695c..fed61fc 100644 --- a/telegram_bot/bot/bot.go +++ b/telegram_bot/bot/bot.go @@ -12,6 +12,10 @@ import ( "github.com/rs/zerolog/log" ) +var emptyKeyboard = echotron.InlineKeyboardMarkup{ + InlineKeyboard: [][]echotron.InlineKeyboardButton{}, +} + type Exec struct { handled bool transitioned bool @@ -77,23 +81,17 @@ func (b *Bot) GetOrCreateJWT(u *echotron.Update) error { } } - // Запрашиваем JWT с актуальными данными пользователя (или создаем нового если не существует) - jwt, err := b.Backend.GetJWTByTelegramUser( - context.Background(), - b.ChatID, - username, - firstName, - lastName, - ) + jwt, err := b.Backend.GetJWTByTelegramUser(context.Background(), b.ChatID, username, firstName, lastName) if err != nil { return err } - // Сохраняем в сессию b.Session.JWT = jwt - if username != nil { - b.Session.UserName = *username + + if firstName != nil { + b.Session.FirstName = *firstName } + return nil } @@ -276,7 +274,11 @@ func (b *Bot) Update(u *echotron.Update) { return } - _ = b.GetOrCreateJWT(u) + err := b.GetOrCreateJWT(u) + if err != nil || b.Session.JWT == "" { + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", emptyKeyboard) + return + } exec := &Exec{} b.exec = exec @@ -336,10 +338,6 @@ func (b *Bot) Update(u *echotron.Update) { } func (b *Bot) cleanupKeyboard(messageID int) { - emptyKeyboard := echotron.InlineKeyboardMarkup{ - InlineKeyboard: [][]echotron.InlineKeyboardButton{}, - } - _, err := b.EditMessageReplyMarkup( echotron.NewMessageID(b.ChatID, messageID), &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, diff --git a/telegram_bot/bot/state.go b/telegram_bot/bot/state.go index 767dd71..23b2573 100644 --- a/telegram_bot/bot/state.go +++ b/telegram_bot/bot/state.go @@ -17,6 +17,6 @@ type State interface { } type Session struct { - UserName string - JWT string + FirstName string + JWT string } diff --git a/telegram_bot/screens/login.go b/telegram_bot/screens/login.go index 5af93a5..03ad136 100644 --- a/telegram_bot/screens/login.go +++ b/telegram_bot/screens/login.go @@ -6,42 +6,35 @@ import ( "example.com/m/bot" "github.com/NicoNex/echotron/v3" - "github.com/rs/zerolog/log" ) type Login struct{} -func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) { - // JWT уже создан в Bot.Update() с актуальными данными пользователя - // Теперь создаем login token (это разовый токен для авторизации на сайте) - var username *string - // Получаем username из сессии если он там сохранен - if b.Session.UserName != "" { - username = &b.Session.UserName - } +const msgHelloLogin = ` +Привет, %s! +👇Нажми кнопку для входа на сайт. +` - token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID, username) +func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) { + token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID) if err != nil { - log.Error().Err(err).Msg("Failed to create login token") - b.SendMessage("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", b.ChatID, nil) + b.SendNew("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", emptyKeyboard) return } - loginURL := b.Backend.GetLoginURL(token) + loginURL := b.Backend.LoginURL(token) usernameDisplay := "аноним" - if username != nil && *username != "" { - usernameDisplay = *username + if b.Session.FirstName != "" { + usernameDisplay = b.Session.FirstName } - message := fmt.Sprintf("Привет, %s!\n\nНажми кнопку ниже для входа на сайт.", usernameDisplay) - b.SendMessageWithURLButton(message, "Войти на сайт", loginURL) -} -func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) { return } + kb := Keyboard(Row(URLButton("Войти на сайт", loginURL))) -func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) { return } - -func (s *Login) Handle(b *bot.Bot, u *echotron.Update) { - b.SetState(&MainMenu{}, bot.NewMessage) - return + b.SendNew(fmt.Sprintf(msgHelloLogin, usernameDisplay), kb) } +func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {} + +func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {} + +func (s *Login) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) } diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go index 20bd182..689d893 100644 --- a/telegram_bot/screens/select_channels_for_purchase.go +++ b/telegram_bot/screens/select_channels_for_purchase.go @@ -158,6 +158,28 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat 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, @@ -313,6 +335,39 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update 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" { diff --git a/telegram_bot/screens/select_workspace.go b/telegram_bot/screens/select_workspace.go index 7be7080..e8a159b 100644 --- a/telegram_bot/screens/select_workspace.go +++ b/telegram_bot/screens/select_workspace.go @@ -6,55 +6,74 @@ import ( "strings" "example.com/m/bot" + "example.com/m/ui" "github.com/NicoNex/echotron/v3" "github.com/rs/zerolog/log" ) type SelectWorkspace struct { - ChannelID string - BackState bot.State + ChannelID string + BackState bot.State + CurrentPage int } -func (s *SelectWorkspace) 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", Keyboard()) - return - } +const msgChooseWorkspace = ` +📁 Выбор workspace для канала +В какой workspace добавить канал? +` - workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt) +const msgSuccessChooseWorkspace = ` +✅ Канал успешно добавлен! +📊 Проект ID: %s +📝 Название: %s +🏷 Статус: %s +` + +func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) { + workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT) if err != nil { - log.Error().Err(err).Msg("Failed to get workspaces") b.SendNew("❌ Не удалось загрузить список workspace'ов", Keyboard()) return } if len(workspaces) == 0 { - b.SendNew("У вас пока нет workspace'ов", Keyboard( - Row(Button("🏠 Главное меню", "back")), - )) + kb := Keyboard(Row(Button("Главное меню", "back"))) + b.SendNew("У вас пока нет workspace'ов", kb) return } - text := "📁 Выбор workspace для канала\n\n" - text += "В какой workspace добавить канал?" + // Создаем кнопки для всех workspace'ов + var allButtons []echotron.InlineKeyboardButton + for _, ws := range workspaces { + allButtons = append(allButtons, Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID))) + } + // Используем компонент пагинации для автоматической раскладки + const workspacesPerPage = 6 var buttons [][]echotron.InlineKeyboardButton - // Создаем кнопки для каждого workspace - for _, ws := range workspaces { - buttons = append(buttons, Row( - Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID)), - )) + // Раскладываем workspace'ы в grid (2 в ряд) с пагинацией + elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{ + CurrentPage: s.CurrentPage, + ItemsPerPage: workspacesPerPage, + ItemsPerRow: 2, + }, allButtons) + buttons = append(buttons, elementRows...) + + // Добавляем навигацию (стрелки появятся только если страниц > 1) + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalItems: len(allButtons), + ItemsPerPage: workspacesPerPage, + }); navRow != nil { + buttons = append(buttons, navRow) } // Кнопка отмены - buttons = append(buttons, Row(Button("❌ Отмена", "cancel"))) + buttons = append(buttons, Row(Button("Отмена", "cancel"))) - keyboard := Keyboard(buttons...) - b.Render(text, keyboard, mode) + kb := Keyboard(buttons...) + b.Render(msgChooseWorkspace, kb, mode) } func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { @@ -65,13 +84,22 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { 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 == "cancel", data == "back": if s.BackState != nil { b.SetState(s.BackState, bot.NewMessage) } case strings.HasPrefix(data, "select_workspace:"): - // Извлекаем workspace_id parts := strings.Split(data, ":") if len(parts) != 2 { log.Error().Str("callback_data", data).Msg("Invalid callback data format") @@ -80,10 +108,6 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { workspaceID := parts[1] - // Показываем индикатор загрузки - b.Edit("⏳ Добавляем канал в workspace...", Keyboard()) - - // Вызываем API для привязки канала к workspace project, err := b.Backend.AttachChannelToWorkspace( context.Background(), s.ChannelID, @@ -91,27 +115,19 @@ func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) { b.ChatID, ) if err != nil { - log.Error().Err(err).Str("channel_id", s.ChannelID).Str("workspace_id", workspaceID).Msg("Failed to attach channel to workspace") b.Edit("❌ Не удалось добавить канал в workspace", Keyboard( Row(Button("← Назад", "back_to_select")), )) return } - // Показываем успешное сообщение - successText := fmt.Sprintf("✅ Канал успешно добавлен!\n\n"+ - "📊 Проект ID: %s\n"+ - "📝 Название: %s\n"+ - "🏷 Статус: %s", - project.ID, - project.Title, - project.Status, + successText := fmt.Sprintf(msgSuccessChooseWorkspace, project.ID, project.Title, project.Status) + kb := Keyboard( + Row(Button("Мои проекты", "my_projects")), + Row(Button("Главное меню", "main_menu")), ) - b.Edit(successText, Keyboard( - Row(Button("📋 Мои проекты", "my_projects")), - Row(Button("🏠 Главное меню", "main_menu")), - )) + b.Edit(successText, kb) case data == "back_to_select": s.Enter(b, bot.EditMessage) diff --git a/telegram_bot/ui/pagination.go b/telegram_bot/ui/pagination.go index ab3fa3b..3e61cd4 100644 --- a/telegram_bot/ui/pagination.go +++ b/telegram_bot/ui/pagination.go @@ -96,3 +96,80 @@ func FormatPageInfo(currentPage, totalPages int) string { } return fmt.Sprintf(" (стр. %d/%d)", currentPage+1, totalPages) } + +// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией +type ElementLayoutConfig struct { + CurrentPage int // Текущая страница (0-indexed) + ItemsPerPage int // Элементов на странице + ItemsPerRow int // Элементов в одном ряду + EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ") +} + +// BuildElementRows автоматически раскладывает элементы с пагинацией. +// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой. +// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты). +// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру. +// +// Пример использования: +// +// config := ui.ElementLayoutConfig{ +// CurrentPage: 0, +// ItemsPerPage: 6, +// ItemsPerRow: 2, +// } +// rows := ui.BuildElementRows(config, allButtons) +// keyboard = append(keyboard, rows...) +func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton { + if len(allItems) == 0 { + return nil + } + + // Параметры по умолчанию + if config.ItemsPerPage <= 0 { + config.ItemsPerPage = 6 + } + if config.ItemsPerRow <= 0 { + config.ItemsPerRow = 2 + } + + // Кнопка-заполнитель по умолчанию + emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"} + if config.EmptyButton != nil { + emptyButton = *config.EmptyButton + } + + // Вычисляем границы страницы + totalPages := CalculatePages(len(allItems), config.ItemsPerPage) + start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems)) + + // Получаем элементы текущей страницы + pageItems := allItems[start:end] + + // Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками + // Это обеспечивает одинаковую высоту клавиатуры на всех страницах + if totalPages >= 2 { + for len(pageItems) < config.ItemsPerPage { + pageItems = append(pageItems, emptyButton) + } + } + + // Раскладываем элементы по рядам + var rows [][]echotron.InlineKeyboardButton + for i := 0; i < len(pageItems); i += config.ItemsPerRow { + var row []echotron.InlineKeyboardButton + + // Добавляем элементы в ряд + for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ { + row = append(row, pageItems[i+j]) + } + + // Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми + for len(row) < config.ItemsPerRow { + row = append(row, emptyButton) + } + + rows = append(rows, row) + } + + return rows +}