From a92f33c22fda8869fa1378bfa6bfb1a0c0ea289c Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sun, 1 Feb 2026 14:36:09 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/adapter/postgres.py | 2 +- src/controller/http_v1/purchases.py | 5 +- tg_bot/backend/backend_client.go | 19 +++++- tg_bot/bot/bot.go | 19 +----- tg_bot/bot/state.go | 7 ++- tg_bot/main.go | 24 +++++++ tg_bot/screens/help.go | 54 ++++++++++++++++ tg_bot/screens/main_menu.go | 8 ++- tg_bot/screens/placements.go | 98 ++++++++++++++++++++++++----- tg_bot/screens/platform_link.go | 42 +++++++++++++ 10 files changed, 235 insertions(+), 43 deletions(-) create mode 100644 tg_bot/screens/help.go create mode 100644 tg_bot/screens/platform_link.go diff --git a/src/adapter/postgres.py b/src/adapter/postgres.py index 6587cd0..48fef56 100644 --- a/src/adapter/postgres.py +++ b/src/adapter/postgres.py @@ -425,7 +425,7 @@ class Postgres(DatabaseBase): return ( await query.prefetch_related('project', 'project__channel', 'channel', 'creative') - .order_by('-created_at') + .order_by('-placement_at', '-created_at') .all() ) diff --git a/src/controller/http_v1/purchases.py b/src/controller/http_v1/purchases.py index 26620ef..94786b7 100644 --- a/src/controller/http_v1/purchases.py +++ b/src/controller/http_v1/purchases.py @@ -3,6 +3,7 @@ from typing import Annotated from fastapi import Depends from fastapi.routing import APIRouter +from fastapi_pagination import Page, paginate from src import deps, dto from src.adapter.jwt import JWTPayload @@ -35,7 +36,7 @@ async def get_placements( workspace_id: uuid.UUID, project_id: uuid.UUID, current_user: Annotated[JWTPayload, Depends(deps.get_current_user)], -) -> dto.GetPlacementsOutput: +) -> Page[dto.PlacementWithPostsOutput]: """Get all placements for a project""" input = dto.GetPlacementsInput( user_id=current_user.user_id, @@ -43,7 +44,7 @@ async def get_placements( project_id=project_id, ) result = await deps.get_usecase().get_placements(input=input) - return result + return paginate(result.placements) # type: ignore[no-any-return] @placements_user_router.get('/{placement_id}') diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index e03f046..dfae601 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -634,6 +634,14 @@ type GetPlacementsOutput struct { Placements []PlacementOutput `json:"placements"` } +type PlacementsPage struct { + Items []PlacementOutput `json:"items"` + Total int `json:"total"` + Page int `json:"page"` + Size int `json:"size"` + Pages int `json:"pages"` +} + type CreativePreviewOutput struct { ID string `json:"id"` Name string `json:"name"` @@ -687,10 +695,15 @@ func (c *Client) GetPlacements( jwt string, workspaceID string, projectID string, -) (*GetPlacementsOutput, error) { - path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements", workspaceID, projectID) + page, size int, +) (*PlacementsPage, error) { + q := url.Values{} + q.Set("page", fmt.Sprint(page)) + q.Set("size", fmt.Sprint(size)) - var resp GetPlacementsOutput + path := fmt.Sprintf("api/v1/workspaces/%s/projects/%s/placements?%s", workspaceID, projectID, q.Encode()) + + var resp PlacementsPage err := c.do( ctx, diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index e74bed0..6d6618e 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -320,6 +320,7 @@ func (b *Bot) Update(u *echotron.Update) { "❌ Ошибка авторизации", echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}}, ) + b.cleanupCallbackUI(u) return } @@ -332,14 +333,7 @@ func (b *Bot) Update(u *echotron.Update) { strings.HasPrefix(u.Message.Text, "/start project_")) if b.CurrentState == nil && !isStartCommand { - _, err := b.SendMessage( - "⚠️ Бот был перезапущен. Извиняемся за неудобства :(\n\nВас отправили в главное меню", - b.ChatID, - &echotron.MessageOptions{ParseMode: echotron.HTML}, - ) - if err != nil { - log.Error().Err(err).Msg("SendMessage failed while sending restart notice") - } + b.Session.WasRestarted = true b.SetState(b.commandRouter("/start"), EditMessage) return } @@ -348,14 +342,7 @@ func (b *Bot) Update(u *echotron.Update) { // 1. Команды (приоритетный маршрут) if u.Message != nil { - switch { - case u.Message.Text == "/start": - b.SetState(b.commandRouter("/start"), NewMessage) - return - case u.Message.Text == "/start login": - b.SetState(b.commandRouter("/start login"), NewMessage) - return - case strings.HasPrefix(u.Message.Text, "/start project_"): + if strings.HasPrefix(u.Message.Text, "/") { b.SetState(b.commandRouter(u.Message.Text), NewMessage) return } diff --git a/tg_bot/bot/state.go b/tg_bot/bot/state.go index 7c9d7c8..b5fec0d 100644 --- a/tg_bot/bot/state.go +++ b/tg_bot/bot/state.go @@ -18,7 +18,8 @@ type State interface { } type Session struct { - FirstName string - JWT string - WorkspaceID string + FirstName string + JWT string + WorkspaceID string + WasRestarted bool } diff --git a/tg_bot/main.go b/tg_bot/main.go index 90b71f1..905ccb6 100644 --- a/tg_bot/main.go +++ b/tg_bot/main.go @@ -14,6 +14,14 @@ import ( "github.com/rs/zerolog/log" ) +var botCommands = []echotron.BotCommand{ + {Command: "start", Description: "Главное меню"}, + {Command: "projects", Description: "Мои проекты"}, + {Command: "placements", Description: "Размещения"}, + {Command: "platform", Description: "Веб-платформа"}, + {Command: "help", Description: "Помощь"}, +} + func main() { initLogger() @@ -24,6 +32,14 @@ func main() { LoginURL: mustEnv("LOGIN_URL"), }) + // Регистрируем команды бота в Telegram + api := echotron.NewAPI(botToken) + if _, err := api.SetMyCommands(nil, botCommands...); err != nil { + log.Error().Err(err).Msg("Failed to set bot commands") + } else { + log.Info().Msg("Bot commands registered successfully") + } + var commandRouter = func(command string) bot.State { createCreativeRe := regexp.MustCompile(`^/start project_(.+)_createcreative$`) @@ -32,6 +48,14 @@ func main() { return &screens.MainMenu{} case command == "/start login": return &screens.Login{} + case command == "/projects": + return &screens.MyProjects{BackState: &screens.MainMenu{}} + case command == "/placements": + return &screens.MyProjects{BackState: &screens.MainMenu{}, OpenPlacements: true} + case command == "/platform": + return &screens.PlatformLink{} + case command == "/help": + return &screens.Help{} case createCreativeRe.MatchString(command): projectID := createCreativeRe.FindStringSubmatch(command)[1] return &screens.AddCreativeStart{Ctx: &screens.AddCreativeCtx{ diff --git a/tg_bot/screens/help.go b/tg_bot/screens/help.go new file mode 100644 index 0000000..32fe913 --- /dev/null +++ b/tg_bot/screens/help.go @@ -0,0 +1,54 @@ +package screens + +import ( + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" +) + +const msgHelp = ` +❓ Помощь + +Команды бота: +/start — Главное меню +/projects — Мои проекты +/placements — Размещения +/platform — Веб-платформа +/help — Эта справка + +Как работать с ботом: +1. Создайте проект и добавьте креативы +2. Выберите каналы для размещения +3. Оформите покупку через безопасную сделку +4. Получите автоматический отчёт + +Поддержка: +Если возникли вопросы, напишите в поддержку. +` + +type Help struct{} + +func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) { + keyboard := Keyboard( + Row(URLButton("Поддержка", "https://example.com")), + Row(Button("↩ В главное меню", "main_menu")), + ) + + b.Render(msgHelp, keyboard, mode) +} + +func (s *Help) HandleCallback(b *bot.Bot, u *echotron.Update) { + switch u.CallbackQuery.Data { + case "main_menu": + b.SetState(&MainMenu{}, bot.EditMessage) + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *Help) HandleMessage(b *bot.Bot, u *echotron.Update) {} + +func (s *Help) Handle(b *bot.Bot, u *echotron.Update) { + b.SetState(&MainMenu{}, bot.NewMessage) +} + +func (s *Help) Exit() {} diff --git a/tg_bot/screens/main_menu.go b/tg_bot/screens/main_menu.go index 6e48367..a410de5 100644 --- a/tg_bot/screens/main_menu.go +++ b/tg_bot/screens/main_menu.go @@ -25,6 +25,12 @@ Smartpost — платформа для покупки и продажи рек ` func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { + msg := msgAbout + if b.Session.WasRestarted { + msg = "⚠️ Бот был перезапущен. Извиняемся за неудобства :(\n\n" + msgAbout + b.Session.WasRestarted = false + } + loadWorkspace := func() string { workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT) if err != nil { @@ -63,7 +69,7 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { Row(Button("Размещения", "placements"), URLButton("Дашборд", "https://example.com")), ) - b.Render(msgAbout, keyboard, mode) + b.Render(msg, keyboard, mode) } func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) { diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go index fd8649d..d014fad 100644 --- a/tg_bot/screens/placements.go +++ b/tg_bot/screens/placements.go @@ -3,9 +3,11 @@ package screens import ( "context" "fmt" + "time" "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" ) type Placements struct { @@ -83,12 +85,16 @@ func (s *Placements) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *Placements) Exit() {} // PlacementsList - экран со списком размещений +const placementsPerPage = 6 +const placementsPerRow = 2 + type PlacementsList struct { ProjectID string ProjectTitle string ProjectTelegramID int64 ProjectUsername string ProjectStatus string + CurrentPage int BackState bot.State } @@ -97,17 +103,24 @@ func (s *PlacementsList) Enter(b *bot.Bot, mode bot.RenderMode) { } func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) { - resp, err := b.Backend.GetPlacements(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID) + page, err := b.Backend.GetPlacements( + context.Background(), + b.Session.JWT, + b.Session.WorkspaceID, + s.ProjectID, + s.CurrentPage+1, + placementsPerPage, + ) if err != nil { b.SendNew("❌ Не удалось загрузить размещения", Keyboard()) return } - text := "Список размещений\n\n" + text := fmt.Sprintf("Список размещений%s\n\n", ui.FormatPageInfo(s.CurrentPage, page.Pages)) - var buttons [][]echotron.InlineKeyboardButton + var rows [][]echotron.InlineKeyboardButton - if len(resp.Placements) == 0 { + if len(page.Items) == 0 { text += `Список размещений пуст. Размещение — это план публикации рекламы в канале: @@ -118,12 +131,13 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) { Создайте первое размещение` } else { - for i, placement := range resp.Placements { - statusSymbol := "●" - if placement.Status == "archived" { - statusSymbol = "■" - } + // Формируем кнопки для каждого размещения + buttons := make([]echotron.InlineKeyboardButton, 0, len(page.Items)) + for i, placement := range page.Items { + // Обратный глобальный номер (последнее = #1, первое = #N) + globalNum := page.Total - (s.CurrentPage * placementsPerPage) - i + // Название канала channelName := "Без названия" if placement.Channel.Title != nil && *placement.Channel.Title != "" { channelName = *placement.Channel.Title @@ -131,20 +145,40 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) { channelName = "@" + *placement.Channel.Username } - buttonText := fmt.Sprintf("%s #%d %s", statusSymbol, i+1, channelName) + // Дата размещения + dateStr := "" + if placement.Details != nil && placement.Details.PlacementAt != nil && *placement.Details.PlacementAt != "" { + dateStr = formatDate(*placement.Details.PlacementAt) + } - buttons = append(buttons, Row( - Button(buttonText, fmt.Sprintf("placement:%s", placement.ID)), - )) + // Текст кнопки: #N • Канал (с пробелом и разделителем) + buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName) + if dateStr != "" { + buttonText += fmt.Sprintf("\n%s", dateStr) + } + + buttons = append(buttons, echotron.InlineKeyboardButton{ + Text: buttonText, + CallbackData: fmt.Sprintf("placement:%s", placement.ID), + }) } + + // Раскладываем по сетке + rows = ui.BuildPageRows(buttons, placementsPerRow, placementsPerPage, page.Pages) + } + + // Навигация (без MiddleButtons - без "Создать размещение") + if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ + CurrentPage: s.CurrentPage, + TotalPages: page.Pages, + }); navRow != nil { + rows = append(rows, navRow) } // Кнопка назад - buttons = append(buttons, Row( - Button("← Назад", "back"), - )) + rows = append(rows, Row(Button("← Назад", "back"))) - keyboard := Keyboard(buttons...) + keyboard := Keyboard(rows...) b.Render(text, keyboard, mode) } @@ -161,6 +195,16 @@ func (s *PlacementsList) HandleCallback(b *bot.Bot, u *echotron.Update) { b.SetState(s.BackState, 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) > 10 && data[:10] == "placement:" { @@ -181,3 +225,23 @@ func (s *PlacementsList) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return func (s *PlacementsList) Handle(_ *bot.Bot, _ *echotron.Update) { return } func (s *PlacementsList) Exit() {} + +// formatDate парсит ISO дату и форматирует как "27 янв" +func formatDate(isoDate string) string { + // Пробуем парсить RFC3339 + t, err := time.Parse(time.RFC3339, isoDate) + if err != nil { + // Пробуем другие форматы + t, err = time.Parse("2006-01-02T15:04:05", isoDate) + if err != nil { + return "" + } + } + + // Русские названия месяцев + months := []string{"янв", "фев", "мар", "апр", "мая", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"} + day := t.Day() + month := months[t.Month()-1] + + return fmt.Sprintf("%d %s", day, month) +} diff --git a/tg_bot/screens/platform_link.go b/tg_bot/screens/platform_link.go new file mode 100644 index 0000000..13edc38 --- /dev/null +++ b/tg_bot/screens/platform_link.go @@ -0,0 +1,42 @@ +package screens + +import ( + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" +) + +const msgPlatform = ` +🌐 Веб-платформа + +Управляйте проектами в браузере — удобно для работы с компьютера. + +Полный функционал доступен на сайте. +` + +type PlatformLink struct{} + +func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) { + keyboard := Keyboard( + Row(URLButton("Открыть платформу", "https://example.com")), + Row(Button("↩ В главное меню", "main_menu")), + ) + + b.Render(msgPlatform, keyboard, mode) +} + +func (s *PlatformLink) HandleCallback(b *bot.Bot, u *echotron.Update) { + switch u.CallbackQuery.Data { + case "main_menu": + b.SetState(&MainMenu{}, bot.EditMessage) + default: + s.Enter(b, bot.NewMessage) + } +} + +func (s *PlatformLink) HandleMessage(b *bot.Bot, u *echotron.Update) {} + +func (s *PlatformLink) Handle(b *bot.Bot, u *echotron.Update) { + b.SetState(&MainMenu{}, bot.NewMessage) +} + +func (s *PlatformLink) Exit() {}