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
+}