refactor + fix добавление проекта

This commit is contained in:
Artem Tsyrulnikov
2026-01-20 15:16:06 +03:00
parent 7f6527652b
commit 4e4f9810d9
10 changed files with 251 additions and 235 deletions

View File

@@ -14,15 +14,40 @@ import (
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
var emptyKeyboard = echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
type exec struct { type exec struct {
handled bool // Событие обработано, дальше не идём handled bool // Событие обработано, дальше не идём
transitioned bool // Был SetState 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 { type Bot struct {
echotron.API echotron.API
mu sync.Mutex mu sync.Mutex
@@ -31,9 +56,7 @@ type Bot struct {
CurrentState State CurrentState State
Session *Session Session *Session
LastMessageID int // ID последнего сообщения с inline кнопками LastMessageID int // ID последнего сообщения с inline кнопками
LastMessageIsMedia bool // True if LastMessageID points to media message lastRender lastRender
LastMessageTextHash string // Hash for last text/caption
LastMessageKBHash string // Hash for last inline keyboard
commandRouter func(cmd string) State // Роутер для команд (например, /start, /help) commandRouter func(cmd string) State // Роутер для команд (например, /start, /help)
globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений globalCallbackRouter func(callbackData string) State // Роутер для глобальных callbacks из уведомлений
Backend *backend.Client Backend *backend.Client
@@ -53,9 +76,12 @@ func NewBot(chatID int64, token string, commandRouter func(string) State, backen
panic("bot: backendClient cannot be nil") panic("bot: backendClient cannot be nil")
} }
api := echotron.NewAPI(token)
ensureBotUsername(api)
return &Bot{ return &Bot{
ChatID: chatID, ChatID: chatID,
API: echotron.NewAPI(token), API: api,
Session: &Session{}, Session: &Session{},
commandRouter: commandRouter, commandRouter: commandRouter,
Backend: backendClient, Backend: backendClient,
@@ -140,7 +166,7 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
} }
func (b *Bot) SetLastMessageIsMedia(isMedia bool) { func (b *Bot) SetLastMessageIsMedia(isMedia bool) {
b.LastMessageIsMedia = isMedia b.lastRender.isMedia = isMedia
} }
func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) { 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.LastMessageID = res.Result.ID
b.LastMessageIsMedia = false b.lastRender.isMedia = false
b.LastMessageTextHash = hashString(text) b.lastRender.textHash = hashString(text)
b.LastMessageKBHash = keyboardHash(keyboard) b.lastRender.kbHash = keyboardHash(keyboard)
log.Info().Int("new_last_msg_id", b.LastMessageID).Msg("Updated LastMessageID in SendNew") 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) newTextHash := hashString(text)
newKBHash := keyboardHash(keyboard) newKBHash := keyboardHash(keyboard)
textChanged := newTextHash != b.LastMessageTextHash textChanged := newTextHash != b.lastRender.textHash
kbChanged := newKBHash != b.LastMessageKBHash kbChanged := newKBHash != b.lastRender.kbHash
if !textChanged && !kbChanged { if !textChanged && !kbChanged {
log.Info().Msg("Edit skipped: message not modified") log.Info().Msg("Edit skipped: message not modified")
@@ -205,7 +231,7 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
var err error var err error
msgID := echotron.NewMessageID(b.ChatID, b.LastMessageID) 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}) _, err = b.EditMessageCaption(msgID, &echotron.MessageCaptionOptions{Caption: text, ParseMode: echotron.HTML, ReplyMarkup: keyboard})
} else { } else {
_, err = b.EditMessageText(text, msgID, &echotron.MessageTextOptions{ReplyMarkup: keyboard, ParseMode: echotron.HTML}) _, 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 err == nil {
if textChanged { if textChanged {
b.LastMessageTextHash = newTextHash b.lastRender.textHash = newTextHash
} }
if kbChanged { if kbChanged {
b.LastMessageKBHash = newKBHash b.lastRender.kbHash = newKBHash
} }
return return
} }
@@ -225,7 +251,7 @@ func (b *Bot) Edit(text string, keyboard echotron.InlineKeyboardMarkup) {
case strings.Contains(err.Error(), "message is not modified"): case strings.Contains(err.Error(), "message is not modified"):
log.Err(err).Msg("EditMessageText ignored: 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"): 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") log.Err(err).Msg("EditMessageText ignored: message has no text")
default: default:
log.Error().Err(err).Msg("EditMessageText") log.Error().Err(err).Msg("EditMessageText")
@@ -284,7 +310,10 @@ func (b *Bot) Update(u *echotron.Update) {
err := b.GetOrCreateJWT(u) err := b.GetOrCreateJWT(u)
if err != nil || b.Session.JWT == "" { if err != nil || b.Session.JWT == "" {
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", emptyKeyboard) b.Edit(
"❌ Ошибка авторизации",
echotron.InlineKeyboardMarkup{InlineKeyboard: [][]echotron.InlineKeyboardButton{{{Text: "↻ Обновить", CallbackData: "refresh"}}}},
)
return return
} }
@@ -307,23 +336,29 @@ func (b *Bot) Update(u *echotron.Update) {
case "/start login": case "/start login":
b.SetState(b.commandRouter("/start login"), NewMessage) b.SetState(b.commandRouter("/start login"), NewMessage)
return return
default: }
b.CurrentState.HandleMessage(b, u) b.CurrentState.HandleMessage(b, u)
if e.handled || e.transitioned { if e.handled || e.transitioned {
return return
} }
} }
}
// 2. Callback запросы // 2. Callback запросы
if u.CallbackQuery != nil { if u.CallbackQuery != nil {
b.cleanupCallbackUI(u) 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 return
} }
// Глобальные callbacks (из уведомлений)
if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil { if newState := b.globalCallbackRouter(u.CallbackQuery.Data); newState != nil {
b.SetState(newState, NewMessage) b.SetState(newState, NewMessage)
return return
@@ -358,6 +393,9 @@ func (b *Bot) Update(u *echotron.Update) {
} }
func (b *Bot) cleanupKeyboard(messageID int) { func (b *Bot) cleanupKeyboard(messageID int) {
var emptyKeyboard = echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
_, err := b.EditMessageReplyMarkup( _, err := b.EditMessageReplyMarkup(
echotron.NewMessageID(b.ChatID, messageID), echotron.NewMessageID(b.ChatID, messageID),
&echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard}, &echotron.MessageReplyMarkupOptions{ReplyMarkup: emptyKeyboard},

View File

@@ -17,12 +17,10 @@ func main() {
initLogger() initLogger()
botToken := mustEnv("TELEGRAM__TOKEN") botToken := mustEnv("TELEGRAM__TOKEN")
loginURL := mustEnv("LOGIN_URL")
baseURL := os.Getenv("BACKEND__BASE_URL")
backendClient := backend.New(backend.Config{ backendClient := backend.New(backend.Config{
BaseURL: baseURL, BaseURL: mustEnv("BACKEND__BASE_URL"),
LoginURL: loginURL, LoginURL: mustEnv("LOGIN_URL"),
}) })
var commandRouter = func(command string) bot.State { var commandRouter = func(command string) bot.State {
@@ -55,8 +53,6 @@ func main() {
dsp := echotron.NewDispatcher(botToken, newBot) dsp := echotron.NewDispatcher(botToken, newBot)
log.Info().Msg("bot starting...")
updateOpts := echotron.UpdateOptions{ updateOpts := echotron.UpdateOptions{
AllowedUpdates: []echotron.UpdateType{ AllowedUpdates: []echotron.UpdateType{
echotron.MessageUpdate, echotron.MessageUpdate,
@@ -70,7 +66,8 @@ func main() {
echotron.SetChatRequestLimit(0, 0) echotron.SetChatRequestLimit(0, 0)
for { 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...") log.Error().Err(err).Msg("dsp.Poll failed, retrying in 5 seconds...")
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
continue continue

View File

@@ -1,6 +1,8 @@
package screens package screens
import ( import (
"fmt"
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot" "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
) )
@@ -15,7 +17,6 @@ const addProjectInstructionText = `
<b>2.</b> Дайте боту следующие права: <b>2.</b> Дайте боту следующие права:
• Удаление сообщений • Удаление сообщений
• Приглашение пользователей • Приглашение пользователей
• Блокировка пользователей
<b>3.</b> После добавления бота: <b>3.</b> После добавления бота:
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически • Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
@@ -29,9 +30,15 @@ type AddProject struct {
} }
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) { 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{ buttons := [][]echotron.InlineKeyboardButton{
Row(URLButton("Добавить", addBotURL)),
Row(Button("← Назад", "back")), Row(Button("← Назад", "back")),
Row(URLButton("Добавить", "tg://resolve?domain=chat_cruiser_bot&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages")),
} }
keyboard := Keyboard(buttons...) keyboard := Keyboard(buttons...)
@@ -48,11 +55,7 @@ func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) {
if s.BackState != nil { if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage) b.SetState(s.BackState, bot.EditMessage)
} }
default:
s.Enter(b, bot.NewMessage)
} }
return
} }
func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return } func (s *AddProject) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }

View File

@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"github.com/NicoNex/echotron/v3" "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/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@@ -96,24 +97,15 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) {
} else { } else {
text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages)) text += fmt.Sprintf("Проекты%s\n\n", ui.FormatPageInfo(s.ProjectPage, page.Pages))
var slots []echotron.InlineKeyboardButton buttons = append(buttons, ui.BuildGrid(
for _, project := range page.Items { page.Items,
slots = append(slots, Button(project.Title, fmt.Sprintf("project:%s", project.ID))) 2,
} addPurchaseProjectsPerPage,
if page.Pages >= 2 { page.Pages,
for len(slots) < addPurchaseProjectsPerPage { func(project backend.Project) (string, string) {
slots = append(slots, Button(" ", "empty")) return project.Title, "project:" + project.ID
} },
} )...)
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)
}
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.ProjectPage, CurrentPage: s.ProjectPage,
@@ -157,24 +149,15 @@ func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode) {
} else { } else {
text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages)) text += fmt.Sprintf("Креативы%s\n\n", ui.FormatPageInfo(s.CreativePage, page.Pages))
var slots []echotron.InlineKeyboardButton buttons = append(buttons, ui.BuildGrid(
for _, creative := range page.Items { page.Items,
slots = append(slots, Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID))) 2,
} addPurchaseCreativesPerPage,
if page.Pages >= 2 { page.Pages,
for len(slots) < addPurchaseCreativesPerPage { func(creative backend.Creative) (string, string) {
slots = append(slots, Button(" ", "empty")) return creative.Name, "creative:" + creative.ID
} },
} )...)
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)
}
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CreativePage, CurrentPage: s.CreativePage,

View File

@@ -2,17 +2,15 @@ package screens
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"github.com/NicoNex/echotron/v3" "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/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const creativesPerPage = 6
type Creatives struct { type Creatives struct {
CurrentPage int CurrentPage int
ProjectID string ProjectID string
@@ -23,23 +21,11 @@ type Creatives struct {
BackState bot.State BackState bot.State
} }
func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) { const creativesPerPage = 6
s.renderCreatives(b, mode) const creativesPerRow = 2
}
func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode) { const msgNoCreatives = `
page, err := b.Backend.GetCreatives(context.Background(), b.Session.JWT, b.Session.WorkspaceID, &s.ProjectID, false, s.CurrentPage+1, creativesPerPage) <b>Креативы</b>
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 = `<b>Креативы</b>
У вас пока нет креативов У вас пока нет креативов
@@ -49,65 +35,51 @@ func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode) {
‣ Шаблон для автопостинга ‣ Шаблон для автопостинга
‣ Варианты для A/B тестирования ‣ Варианты для A/B тестирования
Начните с добавления первого креатива` Начните с добавления первого креатива
} else { `
text = fmt.Sprintf(`<b>Креативы</b>%s
const msgCreatives = `
<b>Креативы</b>
Управление рекламными материалами Управление рекламными материалами
Выберите креатив`, Выберите креатив
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
} }
// Создаем слоты для креативов text := msgCreatives
var slots []echotron.InlineKeyboardButton if len(page.Items) == 0 {
for _, creative := range page.Items { text = msgNoCreatives
slots = append(slots, Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
} }
// Если страниц >= 2, заполняем до 6 слотов пустыми кнопками var kbRows [][]echotron.InlineKeyboardButton
if page.Pages >= 2 {
for len(slots) < creativesPerPage {
slots = append(slots, Button(" ", "empty"))
}
}
// Разбиваем слоты по 2 в ряду rowsGrid := ui.BuildGrid(page.Items, creativesPerRow, creativesPerPage, page.Pages,
for i := 0; i < len(slots); i += 2 { func(creative backend.Creative) (string, string) { return creative.Name, "creative:" + creative.ID },
var row []echotron.InlineKeyboardButton )
row = append(row, slots[i]) 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{ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage, CurrentPage: s.CurrentPage,
TotalPages: page.Pages, TotalPages: page.Pages,
MiddleButtons: []echotron.InlineKeyboardButton{Button(" Добавить", "add_creative")}, MiddleButtons: Row(Button(" Добавить", "add_creative")),
}); navRow != nil { }); navRow != nil {
buttons = append(buttons, navRow) kbRows = append(kbRows, navRow)
} }
// Нижний ряд: Назад + Архив kbRows = append(kbRows, Row(Button("← Назад", "back"), Button("≡ Архив", "archive")))
buttons = append(buttons, Row(
Button("← Назад", "back"),
Button("≡ Архив", "archive"),
))
keyboard := Keyboard(buttons...) kb := Keyboard(kbRows...)
b.Render(text, keyboard, mode) b.Render(text, kb, mode)
messageID := b.LastMessageID updateProjectHeaderMedia(b, b.LastMessageID, text, kb, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
updateProjectHeaderMedia(b, messageID, text, keyboard, s.ProjectTelegramID, s.ProjectTitle, s.ProjectUsername, s.ProjectStatus)
} }
func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { 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": case data == "archive":
// TODO: реализовать экран архива креативов b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(Row(Button("← Назад", "back"))))
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(
Row(Button("← Назад", "back")),
))
case data == "add_creative": case data == "add_creative":
b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{ b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{
@@ -146,17 +115,16 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
}}, bot.EditMessage) }}, bot.EditMessage)
case strings.HasPrefix(data, "creative:"): case strings.HasPrefix(data, "creative:"):
// Открытие деталей креатива creativeID, ok := strings.CutPrefix(data, "creative:")
parts := strings.Split(data, ":") if !ok || creativeID == "" {
if len(parts) == 2 { return
creativeID := parts[1] }
b.SetState(&CreativeDetails{ b.SetState(&CreativeDetails{
CreativeID: creativeID, CreativeID: creativeID,
ProjectID: s.ProjectID, ProjectID: s.ProjectID,
BackState: s, BackState: s,
}, bot.NewMessage) }, bot.NewMessage)
}
default: default:
s.Enter(b, bot.NewMessage) s.Enter(b, bot.NewMessage)
@@ -164,8 +132,8 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
return 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() {} func (s *Creatives) Exit() {}

View File

@@ -5,6 +5,7 @@ import (
) )
var emptyKeyboard = Keyboard() var emptyKeyboard = Keyboard()
var refreshKeyboard = Keyboard(Row(Button("↻ Обновить", "refresh")))
func Button(text, data string) echotron.InlineKeyboardButton { func Button(text, data string) echotron.InlineKeyboardButton {
return echotron.InlineKeyboardButton{ return echotron.InlineKeyboardButton{

View File

@@ -2,28 +2,51 @@ package screens
import ( import (
"context" "context"
"fmt"
"strings" "strings"
"time" "time"
"github.com/NicoNex/echotron/v3" "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/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui" "github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
) )
const projectsPerPage = 6 const projectsPerPage = 6
const projectsPerRow = 2
type MyProjects struct { type MyProjects struct {
CurrentPage int CurrentPage int
BackState bot.State BackState bot.State
OpenPlacements bool OpenPlacements bool
// Поля для управления фоновой горутиной polling
cancel context.CancelFunc cancel context.CancelFunc
lastProjectCount int lastProjectCount int
} }
const msgNoProjects = `
<b>Мои проекты</b>
У вас пока нет подключенных каналов.
<i>Что дает подключение канала:</i>
▸ Управление типом приглашений (публичные/с одобрением)
▸ Автоматическая отправка креативов в канал
▸ Интеграция с планом закупов
▸ Статистика и аналитика подписчиков
▸ Настройка уведомлений
Начните с добавления первого проекта
`
const msgProjects = `
<b>Мои проекты</b>
Управление вашими Telegram-каналами.
Выберите канал
`
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) { func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderProjects(b, mode) 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) { 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) page, err := b.Backend.GetProjects(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CurrentPage+1, projectsPerPage)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get projects") b.SendNew("❌ Не удалось загрузить проекты", refreshKeyboard)
b.SendNew("❌ Не удалось загрузить проекты", Keyboard())
return return
} }
// Сохраняем количество проектов для polling
s.lastProjectCount = page.Total s.lastProjectCount = page.Total
var text string var kbRows [][]echotron.InlineKeyboardButton
var buttons [][]echotron.InlineKeyboardButton
headerTitle := "Мои проекты"
emptyStateText := `У вас пока нет подключенных каналов.
<i>Что дает подключение канала:</i> 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(`<b>%s</b>
%s`, headerTitle, emptyStateText)
} else {
text = fmt.Sprintf(`<b>%s</b>%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{ if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage, CurrentPage: s.CurrentPage,
TotalPages: page.Pages, TotalPages: page.Pages,
MiddleButtons: []echotron.InlineKeyboardButton{Button(" Добавить", "add_project")}, MiddleButtons: Row(Button(" Добавить", "add_project")),
}); navRow != nil { }); navRow != nil {
buttons = append(buttons, navRow) kbRows = append(kbRows, navRow)
} }
// Нижний ряд: Назад + Архив kbRows = append(kbRows, Row(Button("← Назад", "main_menu"), Button("≡ Архив", "archive")))
buttons = append(buttons, Row(
Button("← Назад", "main_menu"), keyboard := Keyboard(kbRows...)
Button("≡ Архив", "archive"),
)) text := msgProjects
if len(page.Items) == 0 {
text = msgNoProjects
}
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode) b.Render(text, keyboard, mode)
} }
@@ -143,16 +111,11 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(&MainMenu{}, bot.EditMessage) b.SetState(&MainMenu{}, bot.EditMessage)
case data == "archive": case data == "archive":
b.Edit("📦 Архив проектов\n\nЭта функция в разработке", Keyboard( b.Edit("Эта функция в разработке", Keyboard(Row(Button("← Назад", "in_dev"))))
Row(Button("← Назад", "back_to_projects")),
))
case data == "add_project": case data == "add_project":
b.SetState(&AddProject{BackState: &MyProjects{}}, bot.EditMessage) b.SetState(&AddProject{BackState: &MyProjects{}}, bot.EditMessage)
case data == "back_to_projects":
s.Enter(b, bot.EditMessage)
case strings.HasPrefix(data, "project:"): case strings.HasPrefix(data, "project:"):
projectID, ok := strings.CutPrefix(data, "project:") projectID, ok := strings.CutPrefix(data, "project:")
if !ok || projectID == "" { if !ok || projectID == "" {
@@ -178,7 +141,7 @@ func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
} }
default: default:
s.Enter(b, bot.NewMessage) s.Enter(b, bot.EditMessage)
} }
return return
} }

View File

@@ -9,7 +9,6 @@ import (
"github.com/NicoNex/echotron/v3" "github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/backend" "github.com/TelegramExchange/tgex-backend/tg_bot/backend"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot" "github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/rs/zerolog/log"
) )
type PlacementDetails struct { 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) { 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) placement, err := b.Backend.GetPlacement(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID, s.PlacementID)
if err != nil { if err != nil {
log.Error().Err(err).Msg("Failed to get placement")
b.SendNew("❌ Не удалось загрузить размещение", Keyboard()) b.SendNew("❌ Не удалось загрузить размещение", Keyboard())
return return
} }

View File

@@ -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)
}

View File

@@ -173,3 +173,47 @@ func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyb
return rows 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
}