From 4e4f9810d9b707e6023a50e83abf7b9dd440a078 Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Tue, 20 Jan 2026 15:16:06 +0300 Subject: [PATCH] =?UTF-8?q?refactor=20+=20fix=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=B5?= =?UTF-8?q?=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tg_bot/bot/bot.go | 92 +++++++++++++++------ tg_bot/main.go | 11 +-- tg_bot/screens/add_project.go | 15 ++-- tg_bot/screens/add_purchase.go | 55 +++++-------- tg_bot/screens/creatives.go | 122 ++++++++++----------------- tg_bot/screens/helpers.go | 1 + tg_bot/screens/my_projects.go | 123 ++++++++++------------------ tg_bot/screens/placement_details.go | 2 - tg_bot/screens/ui/buttons.go | 21 +++++ tg_bot/screens/ui/pagination.go | 44 ++++++++++ 10 files changed, 251 insertions(+), 235 deletions(-) create mode 100644 tg_bot/screens/ui/buttons.go diff --git a/tg_bot/bot/bot.go b/tg_bot/bot/bot.go index 856cca6..efb0123 100644 --- a/tg_bot/bot/bot.go +++ b/tg_bot/bot/bot.go @@ -14,15 +14,40 @@ import ( "github.com/rs/zerolog/log" ) -var emptyKeyboard = echotron.InlineKeyboardMarkup{ - InlineKeyboard: [][]echotron.InlineKeyboardButton{}, -} - type exec struct { handled bool // Событие обработано, дальше не идём transitioned bool // Был SetState } +var botUsername string +var botUsernameOnce sync.Once + +func SetBotUsername(username string) { botUsername = username } + +func BotUsername() string { return botUsername } + +func ensureBotUsername(api echotron.API) { + if botUsername != "" { + return + } + botUsernameOnce.Do(func() { + me, err := api.GetMe() + if err != nil { + log.Error().Err(err).Msg("GetMe failed, bot username will be empty") + return + } + if me.Result != nil { + botUsername = me.Result.Username + } + }) +} + +type lastRender struct { + textHash string + kbHash string + isMedia bool +} + type Bot struct { echotron.API mu sync.Mutex @@ -30,10 +55,8 @@ type Bot struct { exec *exec CurrentState State Session *Session - LastMessageID int // ID последнего сообщения с inline кнопками - LastMessageIsMedia bool // True if LastMessageID points to media message - LastMessageTextHash string // Hash for last text/caption - LastMessageKBHash string // Hash for last inline keyboard + LastMessageID int // ID последнего сообщения с inline кнопками + lastRender lastRender commandRouter func(cmd string) State // Роутер для команд (например, /start, /help) globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений Backend *backend.Client @@ -53,9 +76,12 @@ func NewBot(chatID int64, token string, commandRouter func(string) State, backen panic("bot: backendClient cannot be nil") } + api := echotron.NewAPI(token) + ensureBotUsername(api) + return &Bot{ ChatID: chatID, - API: echotron.NewAPI(token), + API: api, Session: &Session{}, commandRouter: commandRouter, Backend: backendClient, @@ -140,7 +166,7 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R } func (b *Bot) SetLastMessageIsMedia(isMedia bool) { - b.LastMessageIsMedia = isMedia + b.lastRender.isMedia = isMedia } func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) { @@ -175,9 +201,9 @@ func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) { } b.LastMessageID = res.Result.ID - b.LastMessageIsMedia = false - b.LastMessageTextHash = hashString(text) - b.LastMessageKBHash = keyboardHash(keyboard) + b.lastRender.isMedia = false + b.lastRender.textHash = hashString(text) + b.lastRender.kbHash = keyboardHash(keyboard) log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew") } @@ -194,8 +220,8 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { newTextHash := hashString(text) newKBHash := keyboardHash(keyboard) - textChanged := newTextHash != b.LastMessageTextHash - kbChanged := newKBHash != b.LastMessageKBHash + textChanged := newTextHash != b.lastRender.textHash + kbChanged := newKBHash != b.lastRender.kbHash if !textChanged && !kbChanged { log.Info().Msg("Edit skipped: message not modified") @@ -205,7 +231,7 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { var err error msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID) - if b.LastMessageIsMedia { + if b.lastRender.isMedia { _, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{Caption: text, ParseMode: echotron.HTML, ReplyMarkup: keyboard}) } else { _, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML}) @@ -213,10 +239,10 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { if err == nil { if textChanged { - b.LastMessageTextHash = newTextHash + b.lastRender.textHash = newTextHash } if kbChanged { - b.LastMessageKBHash = newKBHash + b.lastRender.kbHash = newKBHash } return } @@ -225,7 +251,7 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) { case strings.Contains(err.Error(), "message is not modified"): log.Err(err).Msg("EditMessageText ignored: message is not modified") case strings.Contains(err.Error(), "no text in the message to edit"): - b.LastMessageIsMedia = true + b.lastRender.isMedia = true log.Err(err).Msg("EditMessageText ignored: message has no text") default: log.Error().Err(err).Msg("EditMessageText") @@ -284,7 +310,10 @@ func (b *Bot) Update(u *echotron.Update) { err := b.GetOrCreateJWT(u) if err != nil || b.Session.JWT == "" { - b.SendNew("❌ Ошибка авторизации. Попробуйте /start", emptyKeyboard) + b.Edit( + "❌ Ошибка авторизации", + echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}}, + ) return } @@ -307,11 +336,11 @@ func (b *Bot) Update(u *echotron.Update) { case "/start login": b.SetState(b.commandRouter("/start login"), NewMessage) return - default: - b.CurrentState.HandleMessage(b, u) - if e.handled || e.transitioned { - return - } + } + + b.CurrentState.HandleMessage(b, u) + if e.handled || e.transitioned { + return } } @@ -319,11 +348,17 @@ func (b *Bot) Update(u *echotron.Update) { if u.CallbackQuery != nil { b.cleanupCallbackUI(u) - if u.CallbackQuery.Data == "empty" { + switch u.CallbackQuery.Data { + case "empty": + return + case "refresh": + if b.CurrentState != nil { + b.CurrentState.Enter(b, EditMessage) + e.handled = true + } return } - // Глобальные callbacks (из уведомлений) if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil { b.SetState(newState, NewMessage) return @@ -358,6 +393,9 @@ func (b *Bot) Update(u *echotron.Update) { } func (b *Bot) cleanupKeyboard(messageID int) { + var emptyKeyboard = echotron.InlineKeyboardMarkup{ + InlineKeyboard: [][]echotron.InlineKeyboardButton{}, + } _, err := b.EditMessageReplyMarkup( echotron.NewMessageID(b.ChatID, messageID), &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, diff --git a/tg_bot/main.go b/tg_bot/main.go index a6a3ffe..77c7700 100644 --- a/tg_bot/main.go +++ b/tg_bot/main.go @@ -17,12 +17,10 @@ func main() { initLogger() botToken := mustEnv("TELEGRAM__TOKEN") - loginURL := mustEnv("LOGIN_URL") - baseURL := os.Getenv("BACKEND__BASE_URL") backendClient := backend.New(backend.Config{ - BaseURL: baseURL, - LoginURL: loginURL, + BaseURL: mustEnv("BACKEND__BASE_URL"), + LoginURL: mustEnv("LOGIN_URL"), }) var commandRouter = func(command string) bot.State { @@ -55,8 +53,6 @@ func main() { dsp := echotron.NewDispatcher(botToken, newBot) - log.Info().Msg("bot starting...") - updateOpts := echotron.UpdateOptions{ AllowedUpdates: []echotron.UpdateType{ echotron.MessageUpdate, @@ -70,7 +66,8 @@ func main() { echotron.SetChatRequestLimit(0, 0) for { - if err := dsp.PollOptions(false, updateOpts); err != nil { + err := dsp.PollOptions(false, updateOpts) + if err != nil { log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...") time.Sleep(5 * time.Second) continue diff --git a/tg_bot/screens/add_project.go b/tg_bot/screens/add_project.go index e454d3e..70b05d7 100644 --- a/tg_bot/screens/add_project.go +++ b/tg_bot/screens/add_project.go @@ -1,6 +1,8 @@ package screens import ( + "fmt" + "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" ) @@ -15,7 +17,6 @@ const addProjectInstructionText = ` 2. Дайте боту следующие права: • Удаление сообщений • Приглашение пользователей - • Блокировка пользователей 3. После добавления бота: • Если у вас 1 рабочее пространство — канал добавится автоматически @@ -29,9 +30,15 @@ type AddProject struct { } func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) { + botUsername := bot.BotUsername() + if botUsername == "" { + botUsername = "chat_cruiser_bot" + } + addBotURL := fmt.Sprintf("https://t.me/%s?startchannel&admin=invite_users+post_messages+edit_messages+delete_messages", botUsername) + buttons := [][]echotron.InlineKeyboardButton{ + Row(URLButton("Добавить", addBotURL)), Row(Button("← Назад", "back")), - Row(URLButton("Добавить", "tg://resolve?domain=chat_cruiser_bot&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages")), } keyboard := Keyboard(buttons...) @@ -48,11 +55,7 @@ func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) { if s.BackState != nil { b.SetState(s.BackState, bot.EditMessage) } - - default: - s.Enter(b, bot.NewMessage) } - return } func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } diff --git a/tg_bot/screens/add_purchase.go b/tg_bot/screens/add_purchase.go index bebc5f9..b362907 100644 --- a/tg_bot/screens/add_purchase.go +++ b/tg_bot/screens/add_purchase.go @@ -5,6 +5,7 @@ import ( "fmt" "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" @@ -96,24 +97,15 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) { } else { text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages)) - var slots []echotron.InlineKeyboardButton - for _, project := range page.Items { - slots = append(slots, Button(project.Title, fmt.Sprintf("project:%s", project.ID))) - } - if page.Pages >= 2 { - for len(slots) < addPurchaseProjectsPerPage { - 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) - } + buttons = append(buttons, ui.BuildGrid( + page.Items, + 2, + addPurchaseProjectsPerPage, + page.Pages, + func(project backend.Project) (string, string) { + return project.Title, "project:" + project.ID + }, + )...) if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ CurrentPage: s.ProjectPage, @@ -157,24 +149,15 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) { } else { text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages)) - var slots []echotron.InlineKeyboardButton - for _, creative := range page.Items { - slots = append(slots, Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) - } - if page.Pages >= 2 { - for len(slots) < addPurchaseCreativesPerPage { - 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) - } + buttons = append(buttons, ui.BuildGrid( + page.Items, + 2, + addPurchaseCreativesPerPage, + page.Pages, + func(creative backend.Creative) (string, string) { + return creative.Name, "creative:" + creative.ID + }, + )...) if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ CurrentPage: s.CreativePage, diff --git a/tg_bot/screens/creatives.go b/tg_bot/screens/creatives.go index 76b3f46..4b59286 100644 --- a/tg_bot/screens/creatives.go +++ b/tg_bot/screens/creatives.go @@ -2,17 +2,15 @@ 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" ) -const creativesPerPage = 6 - type Creatives struct { CurrentPage int ProjectID string @@ -23,23 +21,11 @@ type Creatives struct { BackState bot.State } -func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) { - s.renderCreatives(b, mode) -} +const creativesPerPage = 6 +const creativesPerRow = 2 -func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode) { - page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CurrentPage+1, creativesPerPage) - if err != nil { - log.Error().Err(err).Msg("Failed to get creatives") - b.SendNew("❌ Не удалось загрузить креативы", Keyboard()) - return - } - - var text string - var buttons [][]echotron.InlineKeyboardButton - - if len(page.Items) == 0 { - text = `Креативы +const msgNoCreatives = ` +Креативы У вас пока нет креативов @@ -49,65 +35,51 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode) { ‣ Шаблон для автопостинга ‣ Варианты для A/B тестирования -Начните с добавления первого креатива` - } else { - text = fmt.Sprintf(`Креативы%s +Начните с добавления первого креатива +` + +const msgCreatives = ` +Креативы Управление рекламными материалами -Выберите креатив`, - ui.FormatPageInfo(s.CurrentPage, page.Pages)) +Выберите креатив +` + +func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) { + page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CurrentPage+1, creativesPerPage) + if err != nil { + log.Error().Err(err).Msg("Failed to get creatives") + b.SendNew("❌ Не удалось загрузить креативы", refreshKeyboard) + return } - // Создаем слоты для креативов - var slots []echotron.InlineKeyboardButton - for _, creative := range page.Items { - slots = append(slots, Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) + text := msgCreatives + if len(page.Items) == 0 { + text = msgNoCreatives } - // Если страниц >= 2, заполняем до 6 слотов пустыми кнопками - if page.Pages >= 2 { - for len(slots) < creativesPerPage { - slots = append(slots, Button(" ", "empty")) - } - } + var kbRows [][]echotron.InlineKeyboardButton - // Разбиваем слоты по 2 в ряду - for i := 0; i < len(slots); i += 2 { - var row []echotron.InlineKeyboardButton - row = append(row, slots[i]) + rowsGrid := ui.BuildGrid(page.Items, creativesPerRow, creativesPerPage, page.Pages, + func(creative backend.Creative) (string, string) { return creative.Name, "creative:" + creative.ID }, + ) + kbRows = append(kbRows, rowsGrid...) - // Второй слот в ряду (если есть) - 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_creative")}, + MiddleButtons: Row(Button("+ Добавить", "add_creative")), }); navRow != nil { - buttons = append(buttons, navRow) + kbRows = append(kbRows, navRow) } - // Нижний ряд: Назад + Архив - buttons = append(buttons, Row( - Button("← Назад", "back"), - Button("≡ Архив", "archive"), - )) + kbRows = append(kbRows, Row(Button("← Назад", "back"), Button("≡ Архив", "archive"))) - keyboard := Keyboard(buttons...) - b.Render(text, keyboard, mode) + kb := Keyboard(kbRows...) + b.Render(text, kb, mode) - messageID := b.LastMessageID - updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) + updateProjectHeaderMedia(b, b.LastMessageID, text, kb, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus) } func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { @@ -134,10 +106,7 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { } case data == "archive": - // TODO: реализовать экран архива креативов - b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard( - Row(Button("← Назад", "back")), - )) + b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back")))) case data == "add_creative": b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{ @@ -146,26 +115,25 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { }}, bot.EditMessage) case strings.HasPrefix(data, "creative:"): - // Открытие деталей креатива - parts := strings.Split(data, ":") - if len(parts) == 2 { - creativeID := parts[1] - - b.SetState(&CreativeDetails{ - CreativeID: creativeID, - ProjectID: s.ProjectID, - BackState: s, - }, bot.NewMessage) + creativeID, ok := strings.CutPrefix(data, "creative:") + if !ok || creativeID == "" { + return } + b.SetState(&CreativeDetails{ + CreativeID: creativeID, + ProjectID: s.ProjectID, + BackState: s, + }, bot.NewMessage) + default: s.Enter(b, bot.NewMessage) } return } -func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } +func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} -func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return } +func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) {} func (s *Creatives) Exit() {} diff --git a/tg_bot/screens/helpers.go b/tg_bot/screens/helpers.go index 741888f..01cfcf0 100644 --- a/tg_bot/screens/helpers.go +++ b/tg_bot/screens/helpers.go @@ -5,6 +5,7 @@ import ( ) var emptyKeyboard = Keyboard() +var refreshKeyboard = Keyboard(Row(Button("↻ Обновить", "refresh"))) func Button(text, data string) echotron.InlineKeyboardButton { return echotron.InlineKeyboardButton{ diff --git a/tg_bot/screens/my_projects.go b/tg_bot/screens/my_projects.go index 168638c..4f7948a 100644 --- a/tg_bot/screens/my_projects.go +++ b/tg_bot/screens/my_projects.go @@ -2,28 +2,51 @@ package screens import ( "context" - "fmt" "strings" "time" "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" ) const projectsPerPage = 6 +const projectsPerRow = 2 type MyProjects struct { CurrentPage int BackState bot.State OpenPlacements bool - // Поля для управления фоновой горутиной polling cancel context.CancelFunc lastProjectCount int } +const msgNoProjects = ` +Мои проекты + +У вас пока нет подключенных каналов. + +Что дает подключение канала: + ▸ Управление типом приглашений (публичные/с одобрением) + ▸ Автоматическая отправка креативов в канал + ▸ Интеграция с планом закупов + ▸ Статистика и аналитика подписчиков + ▸ Настройка уведомлений + +Начните с добавления первого проекта +` + +const msgProjects = ` +Мои проекты + +Управление вашими Telegram-каналами. + +Выберите канал +` + func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { s.renderProjects(b, mode) @@ -33,91 +56,36 @@ func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { 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()) + b.SendNew("❌ Не удалось загрузить проекты", refreshKeyboard) return } - // Сохраняем количество проектов для polling s.lastProjectCount = page.Total - var text string - var buttons [][]echotron.InlineKeyboardButton - headerTitle := "Мои проекты" - emptyStateText := `У вас пока нет подключенных каналов. + var kbRows [][]echotron.InlineKeyboardButton -Что дает подключение канала: - ▸ Управление типом приглашений (публичные/с одобрением) - ▸ Автоматическая отправка креативов в канал - ▸ Интеграция с планом закупов - ▸ Статистика и аналитика подписчиков - ▸ Настройка уведомлений + grid := ui.BuildGrid(page.Items, projectsPerRow, projectsPerPage, page.Pages, + func(project backend.Project) (string, string) { return project.Title, "project:" + project.ID }, + ) + kbRows = append(kbRows, grid...) -Начните с добавления первого проекта` - 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")}, + MiddleButtons: Row(Button("+ Добавить", "add_project")), }); navRow != nil { - buttons = append(buttons, navRow) + kbRows = append(kbRows, navRow) } - // Нижний ряд: Назад + Архив - buttons = append(buttons, Row( - Button("← Назад", "main_menu"), - Button("≡ Архив", "archive"), - )) + kbRows = append(kbRows, Row(Button("← Назад", "main_menu"), Button("≡ Архив", "archive"))) + + keyboard := Keyboard(kbRows...) + + text := msgProjects + if len(page.Items) == 0 { + text = msgNoProjects + } - keyboard := Keyboard(buttons...) b.Render(text, keyboard, mode) } @@ -143,16 +111,11 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.EditMessage) case data == "archive": - b.Edit("📦 Архив проектов\n\nЭта функция в разработке", Keyboard( - Row(Button("← Назад", "back_to_projects")), - )) + b.Edit("Эта функция в разработке", Keyboard(Row(Button("← Назад", "in_dev")))) 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 == "" { @@ -178,7 +141,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) { } default: - s.Enter(b, bot.NewMessage) + s.Enter(b, bot.EditMessage) } return } diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index 8b5bd06..94b9679 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -9,7 +9,6 @@ import ( "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/backend" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" - "github.com/rs/zerolog/log" ) type PlacementDetails struct { @@ -25,7 +24,6 @@ func (s *PlacementDetails) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { placement, err := b.Backend.GetPlacement(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, s.PlacementID) if err != nil { - log.Error().Err(err).Msg("Failed to get placement") b.SendNew("❌ Не удалось загрузить размещение", Keyboard()) return } diff --git a/tg_bot/screens/ui/buttons.go b/tg_bot/screens/ui/buttons.go new file mode 100644 index 0000000..e2eef78 --- /dev/null +++ b/tg_bot/screens/ui/buttons.go @@ -0,0 +1,21 @@ +package ui + +import "github.com/NicoNex/echotron/v3" + +// BuildGrid builds button rows from items using a callback builder. +func BuildGrid[T any](items []T, itemsPerRow, itemsPerPage, totalPages int, itemFn func(T) (title string, callback string)) [][]echotron.InlineKeyboardButton { + if len(items) == 0 { + return nil + } + + buttons := make([]echotron.InlineKeyboardButton, 0, len(items)) + for _, item := range items { + title, callback := itemFn(item) + buttons = append(buttons, echotron.InlineKeyboardButton{ + Text: title, + CallbackData: callback, + }) + } + + return BuildPageRows(buttons, itemsPerRow, itemsPerPage, totalPages) +} diff --git a/tg_bot/screens/ui/pagination.go b/tg_bot/screens/ui/pagination.go index 3e61cd4..4c01c67 100644 --- a/tg_bot/screens/ui/pagination.go +++ b/tg_bot/screens/ui/pagination.go @@ -173,3 +173,47 @@ func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyb return rows } + +// BuildPageRows раскладывает элементы текущей страницы без собственной пагинации. +// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты). +func BuildPageRows(pageItems []echotron.InlineKeyboardButton, itemsPerRow, itemsPerPage, totalPages int) [][]echotron.InlineKeyboardButton { + if len(pageItems) == 0 { + return nil + } + + // Параметры по умолчанию + if itemsPerPage <= 0 { + itemsPerPage = 6 + } + if itemsPerRow <= 0 { + itemsPerRow = 2 + } + + // Кнопка-заполнитель по умолчанию + emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"} + + // Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками + if totalPages >= 2 { + for len(pageItems) < itemsPerPage { + pageItems = append(pageItems, emptyButton) + } + } + + // Раскладываем элементы по рядам + var rows [][]echotron.InlineKeyboardButton + for i := 0; i < len(pageItems); i += itemsPerRow { + var row []echotron.InlineKeyboardButton + + for j := 0; j < itemsPerRow && i+j < len(pageItems); j++ { + row = append(row, pageItems[i+j]) + } + + for len(row) < itemsPerRow { + row = append(row, emptyButton) + } + + rows = append(rows, row) + } + + return rows +}