This commit is contained in:
Artem Tsyrulnikov
2026-01-18 13:59:47 +03:00
parent d8202e1141
commit 62e8f98429
62 changed files with 156 additions and 190 deletions

View File

@@ -0,0 +1,47 @@
package screens
import (
"context"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
type AcceptWorkspaceInvite struct {
InviteID string
}
const msgAcceptWorkspaceInviteSuccessfully = `
✅ Вы присоединились к рабочему пространству!
Администратор сможет выдать вам необходимые права в веб-интерфейсе.
`
func (s *AcceptWorkspaceInvite) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt := b.Session.JWT
if jwt == "" {
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", emptyKeyboard)
return
}
err := b.Backend.AcceptWorkspaceInvite(context.Background(), jwt, s.InviteID)
if err != nil {
b.SendNew("❌ Не удалось принять приглашение. Возможно, оно уже было использовано.", emptyKeyboard)
return
}
kb := Keyboard(
Row(Button("Главное меню", "menu")),
)
b.SendNew(msgAcceptWorkspaceInviteSuccessfully, kb)
}
func (s *AcceptWorkspaceInvite) HandleCallback(b *bot.Bot, u *echotron.Update) {}
func (s *AcceptWorkspaceInvite) HandleMessage(_ *bot.Bot, _ *echotron.Update) {}
func (s *AcceptWorkspaceInvite) Handle(b *bot.Bot, _ *echotron.Update) {
b.SetState(&MainMenu{}, bot.EditMessage)
}
func (s *AcceptWorkspaceInvite) Exit() {}

View File

@@ -0,0 +1,412 @@
package screens
import (
"context"
"fmt"
"regexp"
"strconv"
"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"
)
type AddCreativeCtx struct {
CreativeEditorFields
ProjectID string
WorkspaceID string
BackState bot.State
}
type AddCreativeStart struct{ ctx *AddCreativeCtx }
type AddCreativeEdit struct{ ctx *AddCreativeCtx }
type AddCreativeInput struct{ ctx *AddCreativeCtx }
const msgWaitCreative = `
<b> Добавить креатив</b>
📄 <b>Пришлите креатив который хотите добавить</b>
• Обрабатываем любое сообщение Telegram
• Отправляйте сразу оформленный пост
На следующем шаге можно добавить кнопки или изменить контент
`
const msgConfirmCreativeFormat = `
<b> Добавляем этот креатив?</b>
<b>Название креатива:</b> %s
<i>Если нужно изменить название креатива, просто введите его здесь. Название автоматически сохранится. Лимит 200 символов.</i>
`
const msgDownloadMediaError = `
<b>❌ Не удалось загрузить медиа</b>
Попробуйте удалить медиа и добавить заново.
`
const msgInviteLinkRequired = `
<b>❌ Ошибка валидации</b>
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример правильного текста:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
`
const msgInviteLinkTooMany = `
<b>❌ Слишком много ссылок</b>
Текст должен содержать <b>ТОЛЬКО ОДНУ</b> инвайт-ссылку.
У вас их несколько. Удалите лишние.
<b>Нажмите "✎ Текст" ниже чтобы исправить.</b>
`
const msgCreateError = `
<b>❌ Ошибка создания</b>
Не удалось создать креатив.
Попробуйте ещё раз или вернитесь назад.
`
const msgCreativeCreated = `
<b>✅ Креатив успешно создан!</b>
📄 <b>Название:</b> %s
`
const msgCreativeCreatedButtons = `
🔘 <b>Кнопок добавлено:</b> %d
`
func (s *AddCreativeStart) Enter(b *bot.Bot, mode bot.RenderMode) {
kb := Keyboard(Row(Button("« Отмена", "cancel")))
b.Render(msgWaitCreative, kb, mode)
}
func (s *AddCreativeStart) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
if u.CallbackQuery.Data == "cancel" && s.ctx.BackState != nil {
b.SetState(s.ctx.BackState, bot.EditMessage)
}
}
func (s *AddCreativeStart) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
s.ctx.extractCreativeFromMessage(u.Message)
if s.ctx.Name == nil {
name := s.ctx.generateCreativeName()
s.ctx.Name = &name
}
s.ctx.DeleteUserMessage(b, u.Message.ID)
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.NewMessage)
}
func (s *AddCreativeStart) Handle(_ *bot.Bot, _ *echotron.Update) {}
func (s *AddCreativeStart) Exit() {}
func (s *AddCreativeEdit) Enter(b *bot.Bot, _ bot.RenderMode) {
s.ctx.showCreativeConfirmation(b)
}
func (s *AddCreativeEdit) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "cancel":
if s.ctx.BackState != nil {
b.SetState(s.ctx.BackState, bot.EditMessage)
}
case data == "edit_text":
s.ctx.InputMode = inputModeText
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
case data == "add_button":
s.ctx.InputMode = inputModeButtonText
s.ctx.PendingButtonType = "invite"
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
case data == "add_media":
s.ctx.InputMode = inputModeMedia
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
case data == "delete_media":
s.ctx.MediaType = ""
s.ctx.MediaFileID = ""
s.ctx.MediaChanged = true
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
case data == "confirm_create":
s.ctx.createCreative(b)
case strings.HasPrefix(data, "delete_button:"):
indexStr := strings.TrimPrefix(data, "delete_button:")
index, err := strconv.Atoi(indexStr)
if err != nil || index < 0 || index >= len(s.ctx.Buttons) {
return
}
s.ctx.Buttons = append(s.ctx.Buttons[:index], s.ctx.Buttons[index+1:]...)
s.ctx.UpdateCreativePreview(b)
s.Enter(b, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *AddCreativeEdit) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || u.Message.Text == "" {
return
}
newName := u.Message.Text
runes := []rune(newName)
if len(runes) > 200 {
newName = string(runes[:200])
}
s.ctx.Name = &newName
s.ctx.DeleteUserMessage(b, u.Message.ID)
s.Enter(b, bot.EditMessage)
}
func (s *AddCreativeEdit) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddCreativeEdit) Exit() {}
func (s *AddCreativeInput) Enter(b *bot.Bot, _ bot.RenderMode) {
switch s.ctx.InputMode {
case inputModeText:
s.ctx.ShowTextEditPanel(b)
case inputModeButtonText:
s.ctx.ShowAddButtonPanel(b)
case inputModeButtonURL:
s.ctx.ShowButtonURLPanel(b)
case inputModeMedia:
s.ctx.ShowMediaPanel(b)
}
}
func (s *AddCreativeInput) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
switch u.CallbackQuery.Data {
case "button_type_invite":
s.ctx.PendingButtonType = "invite"
s.ctx.ShowAddButtonPanel(b)
case "button_type_custom":
s.ctx.PendingButtonType = "custom"
s.ctx.ShowAddButtonPanel(b)
case "cancel_add_button":
s.ctx.CancelPendingButton(b)
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case "cancel_edit", "cancel_media":
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
}
}
func (s *AddCreativeInput) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
switch s.ctx.InputMode {
case inputModeText:
if u.Message.Text == "" {
return
}
text := ui.FormatMessageHTML(u.Message)
s.ctx.Text = &text
s.ctx.DeleteUserMessage(b, u.Message.ID)
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case inputModeButtonText:
if u.Message.Text == "" {
return
}
buttonText := u.Message.Text
s.ctx.DeleteUserMessage(b, u.Message.ID)
if s.ctx.PendingButtonType == "custom" {
s.ctx.AddCustomButtonPlaceholder(buttonText)
s.ctx.UpdateCreativePreview(b)
s.ctx.InputMode = inputModeButtonURL
b.SetState(&AddCreativeInput{ctx: s.ctx}, bot.EditMessage)
return
}
s.ctx.AddInviteButton(buttonText)
s.ctx.UpdateCreativePreview(b)
s.ctx.PendingButtonType = ""
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case inputModeButtonURL:
if u.Message.Text == "" {
return
}
url := strings.TrimSpace(u.Message.Text)
s.ctx.DeleteUserMessage(b, u.Message.ID)
if !s.ctx.IsValidButtonURL(url, true) {
s.ctx.ShowInvalidButtonURLPanel(b)
return
}
s.ctx.UpdateLastButtonURL(url)
s.ctx.UpdateCreativePreview(b)
s.ctx.PendingButtonType = ""
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
case inputModeMedia:
if !s.ctx.SetMediaFromMessage(u.Message) {
return
}
s.ctx.DeleteUserMessage(b, u.Message.ID)
s.ctx.UpdateCreativePreview(b)
s.ctx.ClearInputMode()
b.SetState(&AddCreativeEdit{ctx: s.ctx}, bot.EditMessage)
}
}
func (s *AddCreativeInput) Handle(_ *bot.Bot, _ *echotron.Update) {}
func (s *AddCreativeInput) Exit() {}
func (s *AddCreativeCtx) extractCreativeFromMessage(message *echotron.Message) {
if message.Text != "" || message.Caption != "" {
text := ui.FormatMessageHTML(message)
if text != "" {
s.Text = &text
}
}
s.SetMediaFromMessage(message)
if message.ReplyMarkup != nil && len(message.ReplyMarkup.InlineKeyboard) > 0 {
for _, row := range message.ReplyMarkup.InlineKeyboard {
for _, button := range row {
if button.URL != "" {
s.Buttons = append(s.Buttons, InlineButton{
Text: button.Text,
URL: button.URL,
})
}
}
}
}
}
func (s *AddCreativeCtx) generateCreativeName() string {
if s.Text != nil && *s.Text != "" {
re := regexp.MustCompile(`<[^>]*>`)
cleanText := re.ReplaceAllString(*s.Text, "")
runes := []rune(strings.TrimSpace(cleanText))
if len(runes) > 10 {
return string(runes[:10]) + "..."
}
if len(runes) > 0 {
return string(runes)
}
}
return "Новый креатив"
}
func (s *AddCreativeCtx) showCreativeConfirmation(b *bot.Bot) {
if s.CreativeMessageID == nil {
s.SendCreativePreview(b)
} else {
s.UpdateCreativePreview(b)
}
escapedName := ui.EscapeHTML(*s.Name)
confirmText := fmt.Sprintf(msgConfirmCreativeFormat, escapedName)
buttons := s.BuildEditorButtons("✓ Сохранить", "confirm_create", "Отмена", "cancel")
s.ShowControlPanel(b, confirmText, buttons)
}
func (s *AddCreativeCtx) createCreative(b *bot.Bot) {
var err error
var mediaData []byte
if s.MediaFileID != "" {
mediaData, err = b.DownloadFileBytes(s.MediaFileID)
if err != nil {
log.Error().Err(err).Msg("Failed to download creative media")
buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")
s.ShowControlPanel(b, msgDownloadMediaError, buttons)
return
}
}
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
for _, button := range s.Buttons {
buttons = append(buttons, backend.CreativeButton{
Text: button.Text,
URL: button.URL,
})
}
creative, err := b.Backend.CreateCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.ProjectID, backend.CreateCreativeInput{
Name: *s.Name,
Text: *s.Text,
MediaType: s.MediaType,
MediaFileID: s.MediaFileID,
MediaData: mediaData,
Buttons: buttons,
})
if err != nil {
log.Error().Err(err).Msg("Failed to create creative")
errMsg := err.Error()
var userMsg string
if strings.Contains(errMsg, "Creative text must contain one invite link") {
userMsg = msgInviteLinkRequired
} else if strings.Contains(errMsg, "Creative text must contain only one invite link") {
userMsg = msgInviteLinkTooMany
} else {
userMsg = msgCreateError
}
buttons := s.BuildEditorButtons("✓ Попробовать снова", "confirm_create", "Отмена", "cancel")
s.ShowControlPanel(b, userMsg, buttons)
return
}
successText := fmt.Sprintf(msgCreativeCreated, creative.Name)
if len(s.Buttons) > 0 {
successText += fmt.Sprintf(msgCreativeCreatedButtons, len(s.Buttons))
}
b.SendNew(successText, Keyboard())
if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
}

View File

@@ -0,0 +1,62 @@
package screens
import (
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
const addProjectInstructionText = `
<b>Как добавить канал в проект</b>
<b>Инструкция:</b>
<b>1.</b> Добавьте бота в ваш канал как администратора
<b>2.</b> Дайте боту следующие права:
• Удаление сообщений
• Приглашение пользователей
• Блокировка пользователей
<b>3.</b> После добавления бота:
• Если у вас <b>1 рабочее пространство</b> — канал добавится автоматически
• Если у вас <b>несколько рабочих пространств</b> — вы получите уведомление с кнопкой выбора
<b>4.</b> Готово! Канал появится в списке проектов
`
type AddProject struct {
BackState bot.State
}
func (s *AddProject) Enter(b *bot.Bot, mode bot.RenderMode) {
buttons := [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(URLButton("Добавить", "tg://resolve?domain=chat_cruiser_bot&startchannel&admin=invite_users+post_messages+edit_messages+delete_messages")),
}
keyboard := Keyboard(buttons...)
b.Render(addProjectInstructionText, keyboard, mode)
}
func (s *AddProject) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
switch u.CallbackQuery.Data {
case "back":
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 }
func (s *AddProject) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddProject) Exit() {}

View File

@@ -0,0 +1,304 @@
package screens
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
const addPurchaseCreativesPerPage = 5
const addPurchaseProjectsPerPage = 6
type AddPurchase struct {
WorkspaceID string
ProjectID string
ProjectTitle string
CreativeID string
CreativeTitle string
ProjectPage int
CreativePage int
ActivePicker string // "" | "project_list" | "creative_list"
BackState bot.State
lastProjects map[string]string
lastCreatives map[string]string
}
func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
// JWT уже создан в Bot.Update(), просто берем из сессии
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
s.renderSelection(b, mode, jwt)
}
func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
text := "<b>Создание закупа</b>\n\n"
text += "Выберите проект и креатив.\n\n"
var buttons [][]echotron.InlineKeyboardButton
projectLabel := "Проект: не выбрано"
if s.ProjectTitle != "" {
projectLabel = fmt.Sprintf("Проект: %s", s.ProjectTitle)
}
creativeLabel := "Креатив: не выбрано"
if s.CreativeTitle != "" {
creativeLabel = fmt.Sprintf("Креатив: %s", s.CreativeTitle)
}
if s.ActivePicker == "" {
buttons = append(buttons, Row(
Button(projectLabel, "pick_project"),
))
buttons = append(buttons, Row(
Button(creativeLabel, "pick_creative"),
))
}
switch s.ActivePicker {
case "project_list":
page, err := b.Backend.GetProjects(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectPage+1,
addPurchaseProjectsPerPage,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get projects")
b.SendNew("❌ Не удалось загрузить проекты", Keyboard())
return
}
s.lastProjects = make(map[string]string)
for _, project := range page.Items {
s.lastProjects[project.ID] = project.Title
}
if len(page.Items) == 0 {
text += "У вас нет проектов. Создайте проект и попробуйте снова.\n\n"
} 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)
}
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.ProjectPage,
TotalPages: page.Pages,
}); navRow != nil {
buttons = append(buttons, navRow)
}
}
buttons = append(buttons, Row(
Button("← Назад", "back_to_selection"),
))
case "creative_list":
if s.ProjectID == "" {
text += "Сначала выберите проект, чтобы увидеть список креативов.\n\n"
buttons = append(buttons, Row(
Button("← Назад", "back_to_selection"),
))
break
}
page, err := b.Backend.GetCreatives(
context.Background(),
jwt,
s.WorkspaceID,
&s.ProjectID,
false,
s.CreativePage+1,
addPurchaseCreativesPerPage,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creatives")
b.SendNew("❌ Не удалось загрузить креативы", Keyboard())
return
}
s.lastCreatives = make(map[string]string)
for _, creative := range page.Items {
s.lastCreatives[creative.ID] = creative.Name
}
if len(page.Items) == 0 {
text += `Для создания закупа сначала нужно создать креатив.
Вернитесь назад и создайте креатив в разделе <b>Креативы</b>`
} 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)
}
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CreativePage,
TotalPages: page.Pages,
}); navRow != nil {
buttons = append(buttons, navRow)
}
}
buttons = append(buttons, Row(
Button("← Назад", "back_to_selection"),
))
}
if s.ActivePicker == "" {
if s.ProjectID != "" && s.CreativeID != "" {
buttons = append(buttons, Row(
Button("← Отмена", "back"),
Button("→ Далее", "next"),
))
} else {
buttons = append(buttons, Row(
Button("← Отмена", "back"),
))
}
}
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "pick_project":
s.ActivePicker = "project_list"
s.Enter(b, bot.EditMessage)
case "pick_creative":
s.ActivePicker = "creative_list"
s.Enter(b, bot.EditMessage)
case "back_to_selection":
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
case "prev":
if s.ActivePicker == "project_list" {
if s.ProjectPage > 0 {
s.ProjectPage--
}
s.Enter(b, bot.EditMessage)
return
}
if s.ActivePicker == "creative_list" {
if s.CreativePage > 0 {
s.CreativePage--
}
s.Enter(b, bot.EditMessage)
return
}
case "next":
if s.ActivePicker == "project_list" {
s.ProjectPage++
s.Enter(b, bot.EditMessage)
return
}
if s.ActivePicker == "creative_list" {
s.CreativePage++
s.Enter(b, bot.EditMessage)
return
}
if s.ProjectID != "" && s.CreativeID != "" {
b.SetState(&SelectChannelsForPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: []PurchaseChannelInput{},
BackState: s.BackState,
}, bot.EditMessage)
}
default:
if len(data) > 8 && data[:8] == "project:" {
projectID := data[8:]
if s.lastProjects != nil {
s.ProjectTitle = s.lastProjects[projectID]
}
s.ProjectID = projectID
s.CreativeID = ""
s.CreativeTitle = ""
s.CreativePage = 0
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
return
}
if len(data) > 9 && data[:9] == "creative:" {
creativeID := data[9:]
if s.lastCreatives != nil {
s.CreativeTitle = s.lastCreatives[creativeID]
}
s.CreativeID = creativeID
s.ActivePicker = ""
s.Enter(b, bot.EditMessage)
return
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *AddPurchase) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *AddPurchase) Exit() {}

View File

@@ -0,0 +1,359 @@
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"
)
type CreativeDetails struct {
CreativeEditorFields // Встраивание общих полей
CreativeID string
ProjectID string
WorkspaceID string
BackState bot.State
// Флаг подтверждения удаления
confirmingDelete bool
}
func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.CreativeID)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative")
b.SendNew("<b>❌ Не удалось загрузить креатив</b>", Keyboard(
Row(Button("← Назад", "back")),
))
return
}
// Заполняем поля из загруженного креатива
s.Name = &creative.Name
if creative.Text != "" {
sanitizedText := ui.SanitizeHTML(creative.Text)
s.Text = &sanitizedText
log.Info().Str("creative_text", creative.Text).Msg("Set creative text from backend")
} else {
log.Info().Msg("Creative text is empty from backend")
}
s.MediaType = creative.MediaType
s.MediaFileID = creative.MediaFileID
s.Buttons = nil
for _, btn := range creative.Buttons {
s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL})
}
s.MediaChanged = false
s.ClearInputMode()
log.Info().
Str("creative_id", s.CreativeID).
Str("name", creative.Name).
Bool("has_text", creative.Text != "").
Bool("s_text_is_nil", s.Text == nil).
Msg("Sending creative preview and panel")
// Отправляем превью и панель управления
s.SendCreativePreview(b)
s.showManagementPanelWithDelete(b)
}
func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
// Обрабатываем общие callbacks (edit_text, add_button, add_media, delete_button, delete_media)
switch {
case data == "cancel", data == "back":
// Отмена - возвращаемся назад
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case data == "edit_text":
s.InputMode = inputModeText
s.ShowTextEditPanel(b)
case data == "add_button":
s.InputMode = inputModeButtonText
s.PendingButtonType = "invite"
s.ShowAddButtonPanel(b)
case data == "add_media":
s.InputMode = inputModeMedia
s.ShowMediaPanel(b)
case data == "delete_media":
s.MediaType = ""
s.MediaFileID = ""
s.MediaChanged = true
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
case data == "cancel_add_button":
// Отменяем добавление кнопки
s.CancelPendingButton(b)
s.showManagementPanelWithDelete(b)
case data == "button_type_invite":
s.PendingButtonType = "invite"
s.InputMode = inputModeButtonText
s.ShowAddButtonPanel(b)
case data == "button_type_custom":
s.PendingButtonType = "custom"
s.InputMode = inputModeButtonText
s.ShowAddButtonPanel(b)
case data == "cancel_edit", data == "cancel_media":
s.ClearInputMode()
s.showManagementPanelWithDelete(b)
case data == "confirm_save":
s.updateCreative(b)
case data == "delete_creative":
if !s.confirmingDelete {
// Первое нажатие - просим подтверждение
s.confirmingDelete = true
s.showManagementPanelWithDelete(b)
} else {
// Второе нажатие - удаляем
s.deleteCreative(b)
}
case data == "confirm_delete":
// Это только для безопасности, главная логика в delete_creative
s.deleteCreative(b)
case strings.HasPrefix(data, "delete_button:"):
parts := strings.Split(data, ":")
if len(parts) == 2 {
var index int
if _, err := fmt.Sscanf(parts[1], "%d", &index); err == nil {
if index >= 0 && index < len(s.Buttons) {
s.Buttons = append(s.Buttons[:index], s.Buttons[index+1:]...)
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
}
}
}
default:
// Сбрасываем флаг подтверждения удаления при любом другом действии
if s.confirmingDelete {
s.confirmingDelete = false
s.showManagementPanelWithDelete(b)
}
}
}
func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil {
return
}
switch s.InputMode {
case inputModeMedia:
if !s.SetMediaFromMessage(u.Message) {
return
}
s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b)
s.ClearInputMode()
s.showManagementPanelWithDelete(b)
return
case inputModeButtonText:
if u.Message.Text == "" {
return
}
buttonText := u.Message.Text
s.DeleteUserMessage(b, u.Message.ID)
// Создаём кнопку с placeholder URL
if s.PendingButtonType == "custom" {
s.AddCustomButtonPlaceholder(buttonText)
s.UpdateCreativePreview(b)
s.InputMode = inputModeButtonURL
s.ShowButtonURLPanel(b)
} else {
s.AddInviteButton(buttonText)
s.UpdateCreativePreview(b)
s.PendingButtonType = ""
s.ClearInputMode()
s.showManagementPanelWithDelete(b)
}
return
case inputModeButtonURL:
if u.Message.Text == "" {
return
}
url := strings.TrimSpace(u.Message.Text)
s.DeleteUserMessage(b, u.Message.ID)
// Простая валидация URL
if !s.IsValidButtonURL(url, true) {
s.ShowInvalidButtonURLPanel(b)
return
}
// Обновляем URL последней кнопки
s.UpdateLastButtonURL(url)
s.ClearInputMode()
s.PendingButtonType = ""
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
return
case inputModeText:
if u.Message.Text == "" {
return
}
text := ui.FormatMessageHTML(u.Message)
s.Text = &text
s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b)
s.ClearInputMode()
s.showManagementPanelWithDelete(b)
return
}
// Обрабатываем имя креатива (если нет других состояний)
if u.Message.Text != "" {
text := ui.FormatMessageHTML(u.Message)
s.Text = &text
s.DeleteUserMessage(b, u.Message.ID)
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
}
}
func (s *CreativeDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *CreativeDetails) Exit() {}
func (s *CreativeDetails) showManagementPanelWithDelete(b *bot.Bot) {
var confirmText, confirmCallback string
if s.confirmingDelete {
confirmText = "✖ Подтвердить удаление"
confirmCallback = "confirm_delete"
} else {
confirmText = "✓ Сохранить"
confirmCallback = "confirm_save"
}
// Добавляем кнопку удаления креатива
deleteButton := Row(Button("⌦ Удалить креатив", "delete_creative"))
s.ShowManagementPanel(b, confirmText, confirmCallback, "cancel", deleteButton)
}
func (s *CreativeDetails) updateCreative(b *bot.Bot) {
// JWT уже создан в Bot.Update(), просто берем из сессии
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("<b>❌ Ошибка авторизации</b>\n\nПопробуйте /start", Keyboard())
return
}
var err error
// Подготавливаем данные для обновления
input := backend.UpdateCreativeInput{
Name: s.Name,
Text: s.Text,
}
if s.MediaChanged {
mediaType := s.MediaType
mediaFileID := s.MediaFileID
input.MediaType = &mediaType
input.MediaFileID = &mediaFileID
if s.MediaFileID != "" {
mediaData, err := b.DownloadFileBytes(s.MediaFileID)
if err != nil {
log.Error().Err(err).Msg("Failed to download creative media")
// Показываем ошибку через панель управления
s.ShowControlPanel(b, "<b>❌ Не удалось загрузить медиа</b>\n\nПопробуйте удалить медиа и добавить заново, или нажмите \"Сохранить\" без изменения медиа.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("✓ Сохранить без медиа", "confirm_save")),
})
return
}
input.MediaData = &mediaData
} else {
emptyData := []byte{}
input.MediaData = &emptyData
}
}
buttons := make([]backend.CreativeButton, 0, len(s.Buttons))
for _, button := range s.Buttons {
buttons = append(buttons, backend.CreativeButton{
Text: button.Text,
URL: button.URL,
})
}
input.Buttons = &buttons
// Отправляем запрос на обновление
_, err = b.Backend.UpdateCreative(
context.Background(),
jwt,
s.WorkspaceID,
s.CreativeID,
input,
)
if err != nil {
log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative")
// Показываем ошибку через панель управления
s.ShowControlPanel(b, "<b>❌ Не удалось сохранить изменения</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("✓ Попробовать снова", "confirm_save")),
})
return
}
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
// Возвращаемся назад
if s.BackState != nil {
b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
b.SetState(s.BackState, bot.EditMessage)
}
}
func (s *CreativeDetails) deleteCreative(b *bot.Bot) {
err := b.Backend.DeleteCreative(context.Background(), b.Session.JWT, s.WorkspaceID, s.CreativeID)
if err != nil {
s.confirmingDelete = false // Сбрасываем флаг подтверждения
s.ShowControlPanel(b, "<b>❌ Не удалось удалить креатив</b>\n\nПроверьте подключение и попробуйте снова.", [][]echotron.InlineKeyboardButton{
Row(Button("← Назад", "back")),
Row(Button("⌦ Попробовать снова", "delete_creative")),
})
return
}
if s.BackState != nil {
b.SendNew("✅ Креатив успешно удален!", Keyboard())
b.SetState(s.BackState, bot.EditMessage)
}
}

View File

@@ -0,0 +1,556 @@
package screens
import (
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
// InlineButton представляет кнопку с URL
type InlineButton struct {
Text string
URL string
}
// CreativeEditorFields содержит общие поля и методы для создания/редактирования креатива
type CreativeEditorFields struct {
// Данные креатива
Name *string
Text *string
Buttons []InlineButton
// Медиа
MediaType string // "photo", "video", "animation"
MediaFileID string // File ID из Telegram
MediaChanged bool
// Режим ввода
InputMode string
// Состояние для добавления кнопки
PendingButtonType string
// ID сообщения с креативом (которое постоянно обновляется)
CreativeMessageID *int
// ID сообщения с панелью управления
ControlPanelMessageID *int
// Предыдущее состояние для определения изменения типа сообщения
previousHasMedia bool
previousMediaType string
previousMediaFileID string
}
const inviteLinkPlaceholder = "{{invite_link}}"
const inviteLinkPreviewURL = "https://t.me/+nlOUmIsLGAlmZTMy" //ссылка приглашения для предпросмотра
const buttonURLPlaceholder = "https://example.com"
const (
inputModeText = "text"
inputModeButtonText = "button_text"
inputModeButtonURL = "button_url"
inputModeMedia = "media"
)
const msgCreativeDefault = `
<i>💭 Я твой креатив
У меня пока нет текста, добавь его ниже.
</i>
`
const msgCreativeManagement = `
<b>👆Превью креатива выше</b> — так он будет выглядеть при отправке.
Выберите, что изменить:
`
func buildCreativeKeyboard(buttons [][]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
keyboard := echotron.InlineKeyboardMarkup{
InlineKeyboard: buttons,
}
if len(buttons) == 0 {
keyboard.InlineKeyboard = [][]echotron.InlineKeyboardButton{}
}
return keyboard
}
// GetCreativeText возвращает текст для превью креатива
func (e *CreativeEditorFields) GetCreativeText() string {
if e.Text == nil {
return msgCreativeDefault
}
text := strings.TrimSpace(ui.SanitizeHTML(*e.Text))
if text == "" {
return msgCreativeDefault
}
// Заменяем пригласительные ссылки с классом tg-link на preview-ссылку
// Находим все вхождения <a class="tg-link">...</a> и заменяем на кликабельную ссылку
for strings.Contains(text, `<a class="tg-link">`) {
startIdx := strings.Index(text, `<a class="tg-link">`)
if startIdx == -1 {
break
}
endIdx := strings.Index(text[startIdx:], "</a>")
if endIdx == -1 {
log.Warn().Msg("Found <a class=\"tg-link\"> but no closing </a>")
break
}
endIdx += startIdx
// Извлекаем текст между тегами
linkText := text[startIdx+len(`<a class="tg-link">`) : endIdx]
// ВАЖНО: Удаляем все HTML-теги из текста ссылки для предотвращения неправильной вложенности
// Это нужно для старых креативов с неправильным HTML
for {
openTag := strings.Index(linkText, "<")
if openTag == -1 {
break
}
closeTag := strings.Index(linkText[openTag:], ">")
if closeTag == -1 {
log.Warn().Str("linkText", linkText).Msg("Found < but no closing >")
break
}
// Удаляем тег
linkText = linkText[:openTag] + linkText[openTag+closeTag+1:]
}
linkText = strings.TrimSpace(linkText)
// Если текст ссылки не пустой, добавляем атрибут href с примером ссылки
// Если пустой, показываем просто текст примера ссылки (без тега)
var replacement string
if linkText != "" {
// Есть текст - добавляем href атрибут к тегу
replacement = fmt.Sprintf(`<a class="tg-link" href="%s">%s</a>`, inviteLinkPreviewURL, linkText)
} else {
// Пусто - показываем просто текст примера ссылки
replacement = fmt.Sprintf(`%s`, inviteLinkPreviewURL)
}
text = text[:startIdx] + replacement + text[endIdx+len("</a>"):]
}
// Также заменяем плейсхолдер {{invite_link}} на preview URL (как в кнопках)
text = strings.ReplaceAll(text, inviteLinkPlaceholder, inviteLinkPreviewURL)
return text
}
// GetCreativeButtons возвращает кнопки для превью креатива
func (e *CreativeEditorFields) GetCreativeButtons() [][]echotron.InlineKeyboardButton {
var buttons [][]echotron.InlineKeyboardButton
for _, btn := range e.Buttons {
url := btn.URL
if url == inviteLinkPlaceholder {
url = inviteLinkPreviewURL
}
buttons = append(buttons, []echotron.InlineKeyboardButton{
{
Text: btn.Text,
URL: url,
},
})
}
return buttons
}
// SendCreativePreview отправляет превью креатива
func (e *CreativeEditorFields) SendCreativePreview(b *bot.Bot) {
creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons()
// Создаем клавиатуру с пустым массивом (не nil!)
keyboard := buildCreativeKeyboard(creativeButtons)
var msgID int
sendText := func() (int, error) {
res, err := b.SendMessage(creativeText, b.ChatID, &echotron.MessageOptions{
ReplyMarkup: keyboard,
ParseMode: echotron.HTML,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
})
if err != nil {
return 0, err
}
if res.Result == nil {
return 0, fmt.Errorf("send message: empty result")
}
return res.Result.ID, nil
}
// Если есть медиа, отправляем с медиа
if e.MediaFileID != "" {
var res echotron.APIResponseMessage
var err error
switch e.MediaType {
case "photo":
res, err = b.SendPhoto(
echotron.NewInputFileID(e.MediaFileID),
b.ChatID,
&echotron.PhotoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "video":
res, err = b.SendVideo(
echotron.NewInputFileID(e.MediaFileID),
b.ChatID,
&echotron.VideoOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
case "animation":
res, err = b.SendAnimation(
echotron.NewInputFileID(e.MediaFileID),
b.ChatID,
&echotron.AnimationOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
}
if err != nil {
log.Error().Err(err).Str("media_type", e.MediaType).Msg("Failed to send media")
// Fallback to text message - используем прямой SendMessage
if id, err := sendText(); err == nil {
msgID = id
}
} else if res.Result != nil {
msgID = res.Result.ID
}
} else {
// Отправляем обычное текстовое сообщение напрямую (не через SendNew)
// чтобы не затронуть LastMessageID и не затронуть cleanup
id, err := sendText()
if err != nil {
log.Error().Err(err).Msg("Failed to send creative preview")
return
}
msgID = id
}
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
e.CreativeMessageID = &msgID
e.previousHasMedia = e.MediaFileID != ""
e.previousMediaType = e.MediaType
e.previousMediaFileID = e.MediaFileID
}
// UpdateCreativePreview обновляет превью креатива
func (e *CreativeEditorFields) UpdateCreativePreview(b *bot.Bot) {
if e.CreativeMessageID == nil {
log.Warn().Msg("UpdateCreativePreview: CreativeMessageID is nil")
// Если превью еще не создано, создаем его
e.SendCreativePreview(b)
return
}
currentHasMedia := e.MediaFileID != ""
// Если тип сообщения изменился (текст ↔ медиа), нужно пересоздать оба сообщения
if currentHasMedia != e.previousHasMedia {
// Удаляем оба сообщения
if e.CreativeMessageID != nil {
b.DeleteMessage(b.ChatID, *e.CreativeMessageID)
e.CreativeMessageID = nil
}
if e.ControlPanelMessageID != nil {
b.DeleteMessage(b.ChatID, *e.ControlPanelMessageID)
e.ControlPanelMessageID = nil
}
// Создаем превью заново (панель будет создана через ShowManagementPanel после этого)
e.SendCreativePreview(b)
return
}
// Тип не изменился - просто редактируем на месте
creativeText := e.GetCreativeText()
creativeButtons := e.GetCreativeButtons()
keyboard := buildCreativeKeyboard(creativeButtons)
msgID := echotron.NewMessageID(b.ChatID, *e.CreativeMessageID)
logEditError := func(msg string, err error) {
textPreview := creativeText
runes := []rune(textPreview)
if len(runes) > 200 {
textPreview = string(runes[:200]) + "..."
}
log.Error().
Err(err).
Int("creative_message_id", *e.CreativeMessageID).
Str("media_type", e.MediaType).
Int("text_len", len([]rune(creativeText))).
Str("text_preview", textPreview).
Msg(msg)
}
// Если есть медиа - редактируем caption
updated := false
if e.MediaFileID != "" {
mediaChanged := e.MediaFileID != e.previousMediaFileID || e.MediaType != e.previousMediaType
if mediaChanged {
var media echotron.InputMedia
switch e.MediaType {
case "photo":
media = echotron.InputMediaPhoto{
Type: echotron.MediaTypePhoto,
Media: echotron.NewInputFileID(e.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "video":
media = echotron.InputMediaVideo{
Type: echotron.MediaTypeVideo,
Media: echotron.NewInputFileID(e.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
case "animation":
media = echotron.InputMediaAnimation{
Type: echotron.MediaTypeAnimation,
Media: echotron.NewInputFileID(e.MediaFileID),
Caption: creativeText,
ParseMode: echotron.HTML,
}
}
if media != nil {
_, err := b.EditMessageMedia(
msgID,
media,
&echotron.MessageMediaOptions{
ReplyMarkup: keyboard,
},
)
if err != nil {
logEditError("Failed to edit message media", err)
} else {
updated = true
}
} else {
log.Warn().Str("media_type", e.MediaType).Msg("Unsupported media type for edit")
}
} else {
_, err := b.EditMessageCaption(
msgID,
&echotron.MessageCaptionOptions{
Caption: creativeText,
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
},
)
if err != nil {
logEditError("Failed to edit media caption", err)
} else {
updated = true
}
}
} else {
// Текстовое сообщение - редактируем текст
_, err := b.EditMessageText(
creativeText,
msgID,
&echotron.MessageTextOptions{
ParseMode: echotron.HTML,
ReplyMarkup: keyboard,
LinkPreviewOptions: echotron.LinkPreviewOptions{
IsDisabled: true,
},
},
)
if err != nil {
logEditError("Failed to edit message text", err)
} else {
updated = true
}
}
if updated {
e.previousHasMedia = currentHasMedia
e.previousMediaType = e.MediaType
e.previousMediaFileID = e.MediaFileID
}
}
func (e *CreativeEditorFields) DeleteUserMessage(b *bot.Bot, messageID int) {
_, err := b.DeleteMessage(b.ChatID, messageID)
if err != nil {
log.Error().Err(err).Msg("Failed to delete user message")
}
}
func (e *CreativeEditorFields) SetMediaFromMessage(message *echotron.Message) bool {
switch {
case message.Photo != nil && len(message.Photo) > 0:
photo := message.Photo[len(message.Photo)-1]
e.MediaType = "photo"
e.MediaFileID = photo.FileID
case message.Video != nil:
e.MediaType = "video"
e.MediaFileID = message.Video.FileID
case message.Animation != nil:
e.MediaType = "animation"
e.MediaFileID = message.Animation.FileID
default:
return false
}
e.MediaChanged = true
return true
}
func (e *CreativeEditorFields) AddInviteButton(text string) {
e.Buttons = append(e.Buttons, InlineButton{
Text: text,
URL: inviteLinkPlaceholder,
})
}
func (e *CreativeEditorFields) AddCustomButtonPlaceholder(text string) {
e.Buttons = append(e.Buttons, InlineButton{
Text: text,
URL: buttonURLPlaceholder,
})
}
func (e *CreativeEditorFields) UpdateLastButtonURL(url string) {
if len(e.Buttons) > 0 {
e.Buttons[len(e.Buttons)-1].URL = url
}
}
func (e *CreativeEditorFields) ClearInputMode() {
e.InputMode = ""
e.PendingButtonType = ""
}
func (e *CreativeEditorFields) CancelPendingButton(b *bot.Bot) {
if e.InputMode == inputModeButtonURL && len(e.Buttons) > 0 {
lastButton := e.Buttons[len(e.Buttons)-1]
if lastButton.URL == buttonURLPlaceholder || lastButton.URL == inviteLinkPlaceholder {
e.Buttons = e.Buttons[:len(e.Buttons)-1]
e.UpdateCreativePreview(b)
}
}
e.ClearInputMode()
}
func (e *CreativeEditorFields) IsValidButtonURL(url string, allowHTTP bool) bool {
if allowHTTP {
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
}
return strings.HasPrefix(url, "https://")
}
// ShowManagementPanel показывает панель управления креативом
// confirmButtonText - текст кнопки подтверждения (например "✓ Сохранить")
// confirmCallback - callback для кнопки подтверждения
// cancelCallback - callback для кнопки отмены
// extraButtons - дополнительные кнопки которые будут добавлены перед финальными кнопками
func (e *CreativeEditorFields) ShowManagementPanel(b *bot.Bot, confirmButtonText, confirmCallback, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) {
text := msgCreativeManagement
buttons := e.BuildEditorButtons(confirmButtonText, confirmCallback, "Назад", cancelCallback, extraButtons...)
e.ShowControlPanel(b, text, buttons)
}
func (e *CreativeEditorFields) BuildEditorButtons(confirmText, confirmCallback, cancelText, cancelCallback string, extraButtons ...[]echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
var buttons [][]echotron.InlineKeyboardButton
// Блок редактирования контента
buttons = append(buttons, Row(
Button("✎ Текст", "edit_text"),
Button(" Медиа", "add_media"),
))
buttons = append(buttons, Row(Button(" Добавить кнопку", "add_button")))
// Кнопка удаления медиа (если медиа добавлено)
if e.MediaFileID != "" {
buttons = append(buttons, Row(
Button("⌫ Убрать медиа", "delete_media"),
))
}
// Кнопки удаления (если есть кнопки)
if len(e.Buttons) > 0 {
var row []echotron.InlineKeyboardButton
for i, btn := range e.Buttons {
row = append(row, Button(
fmt.Sprintf(`⌫ Убрать «%s»`, btn.Text),
fmt.Sprintf("delete_button:%d", i),
))
if len(row) == 2 {
buttons = append(buttons, row)
row = nil
}
}
if len(row) > 0 {
buttons = append(buttons, row)
}
}
// Дополнительные кнопки (например "Удалить креатив")
for _, extraRow := range extraButtons {
buttons = append(buttons, extraRow)
}
// Финальные кнопки
cancelLabel := "← " + cancelText
if e.Text != nil {
buttons = append(buttons, Row(
Button(cancelLabel, cancelCallback),
Button(confirmText, confirmCallback),
))
} else {
buttons = append(buttons, Row(
Button(cancelLabel, cancelCallback),
))
}
return buttons
}
// ShowControlPanel редактирует панель управления с произвольным текстом и кнопками
func (e *CreativeEditorFields) ShowControlPanel(b *bot.Bot, text string, buttons [][]echotron.InlineKeyboardButton) {
if e.ControlPanelMessageID == nil {
// Отправляем новую панель
keyboard := Keyboard(buttons...)
b.SendNew(text, keyboard)
// ВАЖНО: Сохраняем копию значения, а не указатель на переменную!
controlPanelMsgID := b.LastMessageID
e.ControlPanelMessageID = &controlPanelMsgID
} else {
// Редактируем существующую панель
// Временно заменяем LastMessageID на ID панели управления
oldLastMessageID := b.LastMessageID
b.LastMessageID = *e.ControlPanelMessageID
defer func() { b.LastMessageID = oldLastMessageID }()
keyboard := Keyboard(buttons...)
b.Edit(text, keyboard)
}
}

View File

@@ -0,0 +1,101 @@
package screens
import (
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
const msgEditCreativeText = `
<b>✎ Введите текст креатива:</b>
⚠ <b>Важно:</b> Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>
<b>Пример:</b>
<i>Присоединяйтесь к нашему каналу!
https://t.me/+AbCdEfGhIjKlMn</i>
`
const msgAddButtonPrompt = `
<b> Добавить кнопку</b>
Введите <b>текст кнопки:</b>
<i>Например:</i>
• Перейти на сайт
• Написать нам
<i>Ссылка на канал подставится автоматически при создании закупа.</i>
`
const msgAddMediaPrompt = `
<b> Добавить медиа</b>
Отправьте <b>фото, видео или GIF</b>
<i>Это медиа будет отображаться в креативе вместе с текстом</i>
`
const msgButtonURLPrompt = `
<b>✧ Введите URL для кнопки:</b>
URL должен начинаться с <code>http://</code> или <code>https://</code>
<i>Например:</i>
<code>https://example.com</code>
<code>https://t.me/yourchannel</code>
`
const msgInvalidURL = `
<b>❌ Неверный формат URL</b>
URL должен начинаться с <code>http://</code> или <code>https://</code>
Попробуйте ещё раз.
`
func (e *CreativeEditorFields) ShowAddButtonPanel(b *bot.Bot) {
inviteLabel := "Ссылка на канал"
customLabel := "Своя ссылка"
if e.PendingButtonType == "invite" {
inviteLabel = "✓ Ссылка на канал"
}
if e.PendingButtonType == "custom" {
customLabel = "✓ Своя ссылка"
}
e.ShowControlPanel(b, msgAddButtonPrompt, [][]echotron.InlineKeyboardButton{
Row(
Button(inviteLabel, "button_type_invite"),
Button(customLabel, "button_type_custom"),
),
Row(Button("« Отмена", "cancel_add_button")),
})
}
func (e *CreativeEditorFields) ShowTextEditPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgEditCreativeText, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_edit")),
})
}
func (e *CreativeEditorFields) ShowMediaPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgAddMediaPrompt, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_media")),
})
}
func (e *CreativeEditorFields) ShowButtonURLPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgButtonURLPrompt, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_add_button")),
})
}
func (e *CreativeEditorFields) ShowInvalidButtonURLPanel(b *bot.Bot) {
e.ShowControlPanel(b, msgInvalidURL, [][]echotron.InlineKeyboardButton{
Row(Button("« Отмена", "cancel_add_button")),
})
}

183
tg_bot/screens/creatives.go Normal file
View File

@@ -0,0 +1,183 @@
package screens
import (
"context"
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"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
WorkspaceID string
BackState bot.State
}
func (s *Creatives) Enter(b *bot.Bot, mode bot.RenderMode) {
// JWT уже создан в Bot.Update(), просто берем из сессии
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", Keyboard())
return
}
s.renderCreatives(b, mode, jwt)
}
func (s *Creatives) renderCreatives(b *bot.Bot, mode bot.RenderMode, jwt string) {
page, err := b.Backend.GetCreatives(
context.Background(),
jwt,
s.WorkspaceID,
&s.ProjectID,
false, // includeArchived
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 = `<b>Креативы</b>
У вас пока нет креативов
<i>Креатив — это рекламный материал:</i>
‣ Текст объявления
‣ Медиа контент (фото, видео)
‣ Шаблон для автопостинга
‣ Варианты для A/B тестирования
Начните с добавления первого креатива`
} else {
text = fmt.Sprintf(`<b>Креативы</b>%s
Управление рекламными материалами
Выберите креатив`,
ui.FormatPageInfo(s.CurrentPage, page.Pages))
}
// Создаем слоты для креативов
var slots []echotron.InlineKeyboardButton
for _, creative := range page.Items {
slots = append(slots, Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)))
}
// Если страниц >= 2, заполняем до 6 слотов пустыми кнопками
if page.Pages >= 2 {
for len(slots) < creativesPerPage {
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_creative")},
}); navRow != nil {
buttons = append(buttons, navRow)
}
// Нижний ряд: Назад + Архив
buttons = append(buttons, Row(
Button("← Назад", "back"),
Button("≡ Архив", "archive"),
))
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case data == "archive":
// TODO: реализовать экран архива креативов
b.Edit("📦 Архив креативов\n\nЭта функция в разработке", Keyboard(
Row(Button("← Назад", "back")),
))
case data == "add_creative":
b.SetState(&AddCreativeStart{ctx: &AddCreativeCtx{
ProjectID: s.ProjectID,
WorkspaceID: s.WorkspaceID,
BackState: s,
}}, 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,
WorkspaceID: s.WorkspaceID,
BackState: s,
}, bot.NewMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
return
}
func (s *Creatives) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Creatives) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Creatives) Exit() {}

34
tg_bot/screens/helpers.go Normal file
View File

@@ -0,0 +1,34 @@
package screens
import (
"github.com/NicoNex/echotron/v3"
)
var emptyKeyboard = Keyboard()
func Button(text, data string) echotron.InlineKeyboardButton {
return echotron.InlineKeyboardButton{
Text: text,
CallbackData: data,
}
}
func URLButton(text, url string) echotron.InlineKeyboardButton {
return echotron.InlineKeyboardButton{
Text: text,
URL: url,
}
}
func Row(buttons ...echotron.InlineKeyboardButton) []echotron.InlineKeyboardButton { return buttons }
func Keyboard(rows ...[]echotron.InlineKeyboardButton) echotron.InlineKeyboardMarkup {
if len(rows) == 0 {
return echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{},
}
}
return echotron.InlineKeyboardMarkup{
InlineKeyboard: rows,
}
}

42
tg_bot/screens/login.go Normal file
View File

@@ -0,0 +1,42 @@
package screens
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
type Login struct{}
const msgHelloLogin = `
Привет, %s!
Нажми кнопку для входа на сайт. 👇
`
func (s *Login) Enter(b *bot.Bot, mode bot.RenderMode) {
token, err := b.Backend.CreateLoginToken(context.Background(), b.ChatID)
if err != nil {
b.SendNew("❌ Произошла ошибка при создании токена входа. Попробуйте позже.", emptyKeyboard)
return
}
loginURL := b.Backend.LoginURL(token)
usernameDisplay := "аноним"
if b.Session.FirstName != "" {
usernameDisplay = b.Session.FirstName
}
kb := Keyboard(Row(URLButton("Войти на сайт", loginURL)))
b.SendNew(fmt.Sprintf(msgHelloLogin, usernameDisplay), kb)
}
func (s *Login) HandleCallback(b *bot.Bot, u *echotron.Update) {}
func (s *Login) HandleMessage(b *bot.Bot, u *echotron.Update) {}
func (s *Login) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
func (s *Login) Exit() {}

View File

@@ -0,0 +1,53 @@
package screens
import (
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
type MainMenu struct{}
const msgAbout = `
<i>Главное меню</i>
Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц.
• Внутренний счёт и безопасные сделки
• Фиксация цен, дат и форматов
• Автоматические отчёты
• Повторные закупки в пару кликов
Комиссия — 7%. Без скрытых платежей.
`
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(
Button("Мои проекты", "my_projects"),
URLButton("Поддержка", "https://example.com"),
),
Row(
Button("Закупы", "purchases"),
URLButton("Дашборд", "https://example.com"),
),
)
b.Render(msgAbout, keyboard, mode)
}
func (s *MainMenu) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch u.CallbackQuery.Data {
case "my_projects":
b.SetState(&MyProjects{BackState: &MainMenu{}}, bot.EditMessage)
case "purchases":
b.SetState(&MyProjects{BackState: &MainMenu{}, OpenPurchases: true}, bot.EditMessage)
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *MainMenu) HandleMessage(b *bot.Bot, u *echotron.Update) {}
func (s *MainMenu) Handle(b *bot.Bot, u *echotron.Update) { b.SetState(&MainMenu{}, bot.NewMessage) }
func (s *MainMenu) Exit() {}

View File

@@ -0,0 +1,356 @@
package screens
import (
"context"
"fmt"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"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
type MyProjects struct {
CurrentPage int
WorkspaceID string
BackState bot.State
OpenPurchases bool
// Поля для управления фоновой горутиной polling
cancel context.CancelFunc
pollDone chan struct{}
lastProjectCount int
}
func (s *MyProjects) Enter(b *bot.Bot, mode bot.RenderMode) {
// JWT уже создан в Bot.Update(), просто берем из сессии
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start login", Keyboard())
return
}
// Если workspace еще не выбран, получаем список workspace'ов
if s.WorkspaceID == "" {
workspaces, err := b.Backend.GetWorkspaces(context.Background(), jwt)
if err != nil {
log.Error().Err(err).Msg("Failed to get workspaces")
b.SendNew("❌ Не удалось загрузить workspace'ы", Keyboard())
return
}
if len(workspaces) == 0 {
// Автоматически создаем workspace с именем пользователя
username := "My Workspace"
chatInfo, err := b.GetChat(b.ChatID)
if err == nil && chatInfo.Result != nil {
if chatInfo.Result.Username != "" {
username = chatInfo.Result.Username
} else if chatInfo.Result.FirstName != "" {
username = chatInfo.Result.FirstName
if chatInfo.Result.LastName != "" {
username += " " + chatInfo.Result.LastName
}
}
}
log.Info().Str("username", username).Msg("Creating default workspace")
workspace, err := b.Backend.CreateWorkspace(context.Background(), jwt, username)
if err != nil {
log.Error().Err(err).Msg("Failed to create workspace")
b.SendNew("❌ Не удалось создать workspace", Keyboard())
return
}
s.WorkspaceID = workspace.ID
log.Info().Str("workspace_id", s.WorkspaceID).Str("name", workspace.Name).Msg("Workspace created successfully")
} else {
// Используем первый workspace по умолчанию
s.WorkspaceID = workspaces[0].ID
}
}
s.renderProjects(b, mode, jwt)
// Запускаем фоновую горутину для автообновления экрана
s.startPolling(b, jwt)
}
func (s *MyProjects) renderProjects(b *bot.Bot, mode bot.RenderMode, jwt string) {
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage)
if err != nil {
log.Error().Err(err).Msg("Failed to get projects")
b.SendNew("❌ Не удалось загрузить проекты", Keyboard())
return
}
// Сохраняем количество проектов для polling
s.lastProjectCount = page.Total
var text string
var buttons [][]echotron.InlineKeyboardButton
headerTitle := "Мои проекты"
emptyStateText := `У вас пока нет подключенных каналов.
<i>Что дает подключение канала:</i>
▸ Управление типом приглашений (публичные/с одобрением)
▸ Автоматическая отправка креативов в канал
▸ Интеграция с планом закупов
▸ Статистика и аналитика подписчиков
▸ Настройка уведомлений
Начните с добавления первого проекта`
nonEmptyText := "Управление вашими Telegram-каналами.\n\nВыберите канал"
if s.OpenPurchases {
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{
CurrentPage: s.CurrentPage,
TotalPages: page.Pages,
MiddleButtons: []echotron.InlineKeyboardButton{Button(" Добавить", "add_project")},
}); navRow != nil {
buttons = append(buttons, navRow)
}
// Нижний ряд: Назад + Архив
buttons = append(buttons, Row(
Button("← Назад", "menu"),
Button("≡ Архив", "archive"),
))
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *MyProjects) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "menu", data == "main_menu":
b.SetState(&MainMenu{}, bot.EditMessage)
case data == "archive":
b.Edit("📦 Архив проектов\n\nЭта функция в разработке", Keyboard(
Row(Button("← Назад", "back_to_projects")),
))
case data == "add_project":
b.SetState(&AddProject{
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
}, bot.EditMessage)
case data == "back_to_projects":
s.Enter(b, bot.EditMessage)
case strings.HasPrefix(data, "project:"):
// Открытие деталей проекта
parts := strings.Split(data, ":")
if len(parts) == 2 {
projectID := parts[1]
// Получаем проект напрямую по ID
project, err := b.Backend.GetProject(
context.Background(),
s.WorkspaceID,
projectID,
)
if err != nil {
log.Error().Err(err).Str("project_id", projectID).Msg("Failed to get project")
return
}
if s.OpenPurchases {
b.SetState(&Purchases{
WorkspaceID: s.WorkspaceID,
ProjectID: project.ID,
ProjectTitle: project.Title,
BackState: &MyProjects{WorkspaceID: s.WorkspaceID, OpenPurchases: true},
}, bot.EditMessage)
} else {
b.SetState(&ProjectDetails{
Project: project,
WorkspaceID: s.WorkspaceID,
BackState: &MyProjects{WorkspaceID: s.WorkspaceID},
}, bot.EditMessage)
}
}
case strings.HasPrefix(data, "workspace:"):
// Позволяет переключаться между workspace'ами в будущем
parts := strings.Split(data, ":")
if len(parts) == 2 {
s.WorkspaceID = parts[1]
s.CurrentPage = 0
s.Enter(b, bot.EditMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
return
}
func (s *MyProjects) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *MyProjects) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *MyProjects) Exit() {
// Останавливаем polling горутину
if s.cancel != nil {
log.Info().Msg("MyProjects: cancelling polling goroutine")
s.cancel()
}
// Ждем завершения горутины с таймаутом 100ms
// Это не критично - горутина сама завершится при следующем tick
if s.pollDone != nil {
select {
case <-s.pollDone:
log.Info().Msg("MyProjects: polling goroutine finished")
case <-time.After(100 * time.Millisecond):
log.Info().Msg("MyProjects: polling cleanup timeout, goroutine will finish in background")
}
}
}
func (s *MyProjects) startPolling(b *bot.Bot, jwt string) {
// Останавливаем предыдущую горутину если она есть
if s.cancel != nil {
s.cancel()
if s.pollDone != nil {
<-s.pollDone
}
}
// Создаем новый контекст для горутины
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.pollDone = make(chan struct{})
go s.pollProjects(ctx, b, jwt)
}
func (s *MyProjects) pollProjects(ctx context.Context, b *bot.Bot, jwt string) {
defer close(s.pollDone)
// Адаптивный интервал: первые 30 секунд проверяем часто (1 сек),
// потом переходим на более редкий polling (3 сек)
// Это улучшает UX когда пользователь только добавил проект
intervals := []struct {
duration time.Duration
count int
}{
{1 * time.Second, 30}, // Первые 30 секунд - каждую секунду
{3 * time.Second, -1}, // Потом - каждые 3 секунды (бесконечно)
}
currentIntervalIdx := 0
tickCount := 0
ticker := time.NewTicker(intervals[0].duration)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Info().Msg("MyProjects polling: context cancelled, stopping")
return
case <-ticker.C:
tickCount++
// Проверяем, нужно ли переключить интервал
if currentIntervalIdx < len(intervals)-1 {
if intervals[currentIntervalIdx].count > 0 && tickCount >= intervals[currentIntervalIdx].count {
currentIntervalIdx++
tickCount = 0
ticker.Reset(intervals[currentIntervalIdx].duration)
log.Info().
Dur("new_interval", intervals[currentIntervalIdx].duration).
Msg("MyProjects polling: switching to slower interval")
}
}
// Проверяем количество проектов
page, err := b.Backend.GetProjects(context.Background(), jwt, s.WorkspaceID, s.CurrentPage+1, projectsPerPage)
if err != nil {
log.Error().Err(err).Msg("MyProjects polling: failed to get projects")
continue
}
// Если количество изменилось - перерендериваем
if page.Total != s.lastProjectCount {
log.Info().
Int("old_count", s.lastProjectCount).
Int("new_count", page.Total).
Msg("MyProjects polling: project count changed, re-rendering")
s.lastProjectCount = page.Total
s.renderProjects(b, bot.EditMessage, jwt)
}
}
}
}

View File

@@ -0,0 +1,206 @@
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/rs/zerolog/log"
)
type ProjectDetails struct {
Project *backend.Project
WorkspaceID string
BackState bot.State
confirmingLinkTypeChange bool // Флаг для двухшагового подтверждения смены типа ссылки
}
func (s *ProjectDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
if s.Project == nil {
b.SendNew("❌ Проект не найден", Keyboard(
Row(Button("← Назад", "back_to_projects")),
))
return
}
text := s.formatProjectDetails()
keyboard := s.buildKeyboard()
b.Render(text, keyboard, mode)
}
func (s *ProjectDetails) formatProjectDetails() string {
p := s.Project
text := fmt.Sprintf("<b>%s</b>\n\n", p.Title)
// Информация о канале
if p.Username != nil && *p.Username != "" {
text += fmt.Sprintf("▸ Telegram: @%s\n", *p.Username)
} else {
text += "▸ Telegram: <i>не привязан</i>\n"
}
// Статус проекта
var statusSymbol string
var statusText string
switch p.Status {
case "active":
statusSymbol = "●"
statusText = "Активный"
case "inactive":
statusSymbol = "○"
statusText = "Неактивен"
case "archived":
statusSymbol = "■"
statusText = "Архивный"
case "paused":
statusSymbol = "◐"
statusText = "Приостановлен"
default:
statusSymbol = "○"
statusText = p.Status
}
text += fmt.Sprintf("▸ Статус: %s %s\n", statusSymbol, statusText)
text += "\nУправление проектом"
return text
}
func (s *ProjectDetails) buildKeyboard() echotron.InlineKeyboardMarkup {
var buttons [][]echotron.InlineKeyboardButton
// Первый ряд: Креативы и Закупы
buttons = append(buttons, Row(
Button("Креативы", fmt.Sprintf("creatives:%s", s.Project.ID)),
Button("Закупы", fmt.Sprintf("purchases:%s", s.Project.ID)),
))
// Второй ряд: Тип вступления по умолчанию для закупов (с подтверждением)
var linkTypeButtonText string
if s.confirmingLinkTypeChange {
// Показываем подтверждение
var newType string
if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "По заявке"
} else {
newType = "Свободное"
}
linkTypeButtonText = fmt.Sprintf("Изменить → %s", newType)
} else {
// Показываем текущий тип вступления
var currentType string
if s.Project.PurchaseInviteTypeDefault == "public" {
currentType = "Свободное"
} else {
currentType = "По заявке"
}
linkTypeButtonText = fmt.Sprintf("Вступление: %s", currentType)
}
buttons = append(buttons, Row(
Button(linkTypeButtonText, fmt.Sprintf("link_type:%s", s.Project.ID)),
))
// Нижний ряд: Назад и Архивировать
buttons = append(buttons, Row(
Button("← Назад", "back_to_projects"),
Button("≡ Архивировать", fmt.Sprintf("archive:%s", s.Project.ID)),
))
return Keyboard(buttons...)
}
func (s *ProjectDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "back_to_projects", data == "back":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case strings.HasPrefix(data, "creatives:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
b.SetState(&Creatives{
ProjectID: s.Project.ID,
WorkspaceID: s.WorkspaceID,
BackState: s,
}, bot.EditMessage)
case strings.HasPrefix(data, "purchases:"):
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
b.SetState(&Purchases{
WorkspaceID: s.WorkspaceID,
ProjectID: s.Project.ID,
ProjectTitle: s.Project.Title,
BackState: s,
}, bot.EditMessage)
case data == "back_to_project":
s.confirmingLinkTypeChange = false // Сбрасываем флаг подтверждения
s.Enter(b, bot.EditMessage)
case strings.HasPrefix(data, "link_type:"):
if !s.confirmingLinkTypeChange {
// Первое нажатие - включаем режим подтверждения
s.confirmingLinkTypeChange = true
s.Enter(b, bot.EditMessage)
} else {
// Второе нажатие - меняем тип ссылки
var newType string
if s.Project.PurchaseInviteTypeDefault == "public" {
newType = "approval"
} else {
newType = "public"
}
log.Info().
Str("workspace_id", s.WorkspaceID).
Str("project_id", s.Project.ID).
Str("old_type", s.Project.PurchaseInviteTypeDefault).
Str("new_type", newType).
Msg("Updating project invite link type")
updatedProject, err := b.Backend.UpdateProjectInviteLinkType(
context.Background(),
b.Session.JWT,
s.WorkspaceID,
s.Project.ID,
newType,
)
if err != nil {
b.SendNew(fmt.Sprintf("❌ Ошибка при изменении типа ссылки: %v", err), Keyboard(
Row(Button("← Назад", "back_to_project")),
))
return
}
// Обновляем проект и сбрасываем флаг подтверждения
s.Project = updatedProject
s.confirmingLinkTypeChange = false
s.Enter(b, bot.EditMessage)
}
default:
// Для остальных кнопок показываем заглушку
b.Edit(fmt.Sprintf("🚧 Функция в разработке\n\nCallback: %s", data), Keyboard(
Row(Button("← Назад", "back_to_project")),
))
}
return
}
func (s *ProjectDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *ProjectDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *ProjectDetails) Exit() {}

View File

@@ -0,0 +1,273 @@
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/rs/zerolog/log"
)
type PurchaseDetails struct {
WorkspaceID string
ProjectID string
PurchaseID string
BackState bot.State
}
func (s *PurchaseDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
s.renderDetails(b, mode, jwt)
}
func (s *PurchaseDetails) renderDetails(b *bot.Bot, mode bot.RenderMode, jwt string) {
purchase, err := b.Backend.GetPurchase(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
s.PurchaseID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchase")
b.SendNew("❌ Не удалось загрузить закуп", Keyboard())
return
}
placementsByChannel := s.loadPlacementsByChannel(b, jwt, purchase)
// Получаем информацию о креативе
creative, err := b.Backend.GetCreative(
context.Background(),
jwt,
s.WorkspaceID,
purchase.CreativeID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get creative")
creative = nil
}
totalPlacements := 0
for _, items := range placementsByChannel {
totalPlacements += len(items)
}
text := "<b>Детали закупа</b>\n\n"
// Информация о креативе
if creative != nil {
text += fmt.Sprintf("• <b>Креатив:</b> %s\n", creative.Name)
} else {
text += fmt.Sprintf("• <b>Креатив ID:</b> %s\n", purchase.CreativeID)
}
// Статус закупа
statusSymbol := "●"
statusText := "Активный"
if purchase.Status == "archived" {
statusSymbol = "■"
statusText = "Архивирован"
}
text += fmt.Sprintf("• <b>Статус:</b> %s %s\n", statusSymbol, statusText)
text += fmt.Sprintf("• <b>Каналов:</b> %d\n", len(purchase.Channels))
text += fmt.Sprintf("• <b>Размещений:</b> %d\n\n", totalPlacements)
// Информация о каналах
if len(purchase.Channels) == 0 {
text += "<i>Каналы не добавлены</i>\n"
} else {
text += fmt.Sprintf("<b>Каналы (%d):</b>\n\n", len(purchase.Channels))
for i, ch := range purchase.Channels {
chStatusText := formatPurchaseChannelStatus(ch.Status)
// Название канала
channelName := "Без названия"
if ch.Channel.Title != nil {
channelName = *ch.Channel.Title
} else if ch.Channel.Username != nil {
channelName = "@" + *ch.Channel.Username
}
text += fmt.Sprintf("%d. <b>%s</b>\n", i+1, channelName)
text += fmt.Sprintf(" ◦ <i>%s</i>\n", chStatusText)
// Стоимость
if ch.Details != nil && ch.Details.Cost != nil {
text += fmt.Sprintf(" ◦ Стоимость: %.0f₽\n", ch.Details.Cost.Value)
}
// Дата размещения (план)
if ch.Details != nil && ch.Details.PlacementAt != nil && *ch.Details.PlacementAt != "" {
text += fmt.Sprintf(" ◦ План: %s\n", *ch.Details.PlacementAt)
}
// Комментарий
if ch.Comment != nil && *ch.Comment != "" {
text += fmt.Sprintf(" ◦ %s\n", *ch.Comment)
}
// Информация о размещении
placements := placementsByChannel[ch.ID]
if len(placements) == 0 {
text += " ◦ Размещение: <b>нет</b>\n"
} else {
placement := placements[0]
text += fmt.Sprintf(" ◦ Размещение: <b>есть</b> (%d)\n", len(placements))
if placement.Status != "" {
text += fmt.Sprintf(" ◦ Статус размещения: %s\n", formatPlacementStatus(placement.Status))
}
if placement.PlacementDate != "" {
text += fmt.Sprintf(" ◦ Дата: %s\n", placement.PlacementDate)
}
if placement.AdPostURL != nil && *placement.AdPostURL != "" {
text += fmt.Sprintf(" ◦ Пост: %s\n", *placement.AdPostURL)
}
if placement.SubscriptionsCount > 0 {
text += fmt.Sprintf(" ◦ Подписки: %d\n", placement.SubscriptionsCount)
}
}
// Тип invite link
inviteLinkTypeText := "публичная"
if ch.InviteLinkType == "approval" {
inviteLinkTypeText = "с одобрением"
}
text += fmt.Sprintf(" ◦ Ссылка: %s\n", inviteLinkTypeText)
text += "\n"
}
}
var buttons [][]echotron.InlineKeyboardButton
// Кнопка назад
buttons = append(buttons, Row(
Button("← Назад", "back"),
))
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *PurchaseDetails) loadPlacementsByChannel(
b *bot.Bot,
jwt string,
purchase *backend.Purchase,
) map[string][]backend.Placement {
placementsByChannel := make(map[string][]backend.Placement)
if purchase == nil || purchase.CreativeID == "" || len(purchase.Channels) == 0 {
return placementsByChannel
}
projectID := s.ProjectID
creativeID := purchase.CreativeID
pageSize := 50
maxPages := 5
foundChannels := make(map[string]bool)
for page := 1; page <= maxPages; page++ {
resp, err := b.Backend.GetPlacements(
context.Background(),
jwt,
s.WorkspaceID,
&projectID,
nil,
&creativeID,
false,
page,
pageSize,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get placements for purchase details")
break
}
for _, placement := range resp.Items {
if placement.PurchaseID == nil || *placement.PurchaseID != purchase.ID {
continue
}
if placement.PurchaseChannelID == nil || *placement.PurchaseChannelID == "" {
continue
}
chID := *placement.PurchaseChannelID
placementsByChannel[chID] = append(placementsByChannel[chID], placement)
foundChannels[chID] = true
}
if resp.Pages <= page || len(foundChannels) == len(purchase.Channels) {
break
}
}
return placementsByChannel
}
func formatPurchaseChannelStatus(status string) string {
switch status {
case "planned":
return "Планируется"
case "approved":
return "Согласовано"
case "rejected":
return "Отклонено"
case "in_progress":
return "В работе"
case "completed":
return "Завершено"
default:
return "Неизвестно"
}
}
func formatPlacementStatus(status string) string {
normalized := strings.TrimSpace(strings.ToLower(status))
switch normalized {
case "planned":
return "Планируется"
case "approved":
return "Согласовано"
case "rejected":
return "Отклонено"
case "in_progress":
return "В работе"
case "completed":
return "Размещено"
default:
return status
}
}
func (s *PurchaseDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
default:
s.Enter(b, bot.NewMessage)
}
}
func (s *PurchaseDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *PurchaseDetails) Exit() {}

File diff suppressed because it is too large Load Diff

149
tg_bot/screens/purchases.go Normal file
View File

@@ -0,0 +1,149 @@
package screens
import (
"context"
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/rs/zerolog/log"
)
type Purchases struct {
WorkspaceID string
ProjectID string
ProjectTitle string
BackState bot.State
}
func (s *Purchases) Enter(b *bot.Bot, mode bot.RenderMode) {
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
s.renderPurchases(b, mode, jwt)
}
func (s *Purchases) renderPurchases(b *bot.Bot, mode bot.RenderMode, jwt string) {
purchases, err := b.Backend.GetPurchases(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
)
if err != nil {
log.Error().Err(err).Msg("Failed to get purchases")
b.SendNew("❌ Не удалось загрузить закупы", Keyboard())
return
}
text := "<b>Закупы</b>\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(purchases) == 0 {
text += `Список закупов пуст.
<i>Закуп — это план размещения рекламы в каналах:</i>
‣ Выбор креатива
‣ Список каналов для размещения
‣ Планируемая стоимость
‣ Статус выполнения
Создайте первый закуп`
} else {
text += "Управление закупами для проекта.\n\n"
text += "Выберите закуп для деталей:\n\n"
for i, purchase := range purchases {
// Определяем статус
statusSymbol := "●"
switch purchase.Status {
case "active":
statusSymbol = "●"
case "archived":
statusSymbol = "■"
}
// Считаем каналы по статусам
totalChannels := len(purchase.Channels)
completedChannels := 0
for _, ch := range purchase.Channels {
if ch.Status == "completed" {
completedChannels++
}
}
buttonText := fmt.Sprintf("%s Закуп #%d (%d/%d каналов)",
statusSymbol,
i+1,
completedChannels,
totalChannels,
)
buttons = append(buttons, Row(
Button(buttonText, fmt.Sprintf("purchase:%s", purchase.ID)),
))
}
}
// Кнопка создания нового закупа
buttons = append(buttons, Row(
Button(" Создать закуп", "add_purchase"),
))
// Кнопка назад
buttons = append(buttons, Row(
Button("← Назад", "back"),
))
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
case "add_purchase":
b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ActivePicker: "",
BackState: s,
}, bot.EditMessage)
default:
// Проверяем, не нажали ли на конкретный закуп
if len(data) > 9 && data[:9] == "purchase:" {
purchaseID := data[9:]
b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
PurchaseID: purchaseID,
BackState: s,
}, bot.EditMessage)
} else {
s.Enter(b, bot.NewMessage)
}
}
}
func (s *Purchases) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Purchases) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *Purchases) Exit() {}

View File

@@ -0,0 +1,379 @@
package screens
import (
"context"
"fmt"
"regexp"
"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"
)
type PurchaseChannelInput struct {
Username string
PlannedCost *float64
Comment *string
}
type SelectChannelsForPurchase struct {
WorkspaceID string
ProjectID string
ProjectTitle string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
CurrentPage int
InvalidUsernames []string
BackState bot.State
}
var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderChannelSelection(b, mode)
}
func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.RenderMode) {
text := "<b>Создание закупа</b>\n\n"
text += "<b>Шаг 2/2:</b> Добавление каналов\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(s.Channels) == 0 {
text += `Добавьте каналы для размещения рекламы
Отправьте username канала (без @)
Например: <code>channel_name</code>
После добавления всех каналов нажмите <b>Далее</b>`
} else {
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
for i, ch := range s.Channels {
channelText := fmt.Sprintf(" %d. @%s", i+1, ch.Username)
if ch.PlannedCost != nil {
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
}
text += channelText + "\n"
}
text += "\n"
const channelsPerPage = 6
if s.CurrentPage*channelsPerPage >= len(s.Channels) {
s.CurrentPage = 0
}
start, end := ui.GetPageBounds(s.CurrentPage, channelsPerPage, len(s.Channels))
var slots []echotron.InlineKeyboardButton
for i := start; i < end; i++ {
ch := s.Channels[i]
buttonText := fmt.Sprintf("@%s", ch.Username)
if ch.PlannedCost != nil {
buttonText = fmt.Sprintf("@%s — %.0f₽", ch.Username, *ch.PlannedCost)
}
slots = append(slots, Button(buttonText, fmt.Sprintf("remove_channel:%d", i)))
}
if len(s.Channels) > channelsPerPage {
for len(slots) < channelsPerPage {
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)
}
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalPages: pages,
})
if navRow != nil {
buttons = append(buttons, navRow)
}
text += "\nДобавьте еще каналы или создайте закуп"
// Кнопка создания закупа (доступна только если есть каналы)
}
if len(s.InvalidUsernames) > 0 {
text += fmt.Sprintf("\n\n⚠ Пропущены: %s", strings.Join(s.InvalidUsernames, ", "))
s.InvalidUsernames = nil
}
// Кнопки навигации
if len(s.Channels) > 0 {
buttons = append(buttons, Row(
Button("← Назад", "back"),
Button("→ Далее", "next_step"),
))
} else {
buttons = append(buttons, Row(
Button("← Назад", "back"),
))
}
keyboard := Keyboard(buttons...)
b.Render(text, keyboard, mode)
}
func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch data {
case "back":
// Возвращаемся к выбору креатива
b.SetState(&AddPurchase{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
ActivePicker: "",
BackState: s.BackState,
}, bot.EditMessage)
case "next_step":
if len(s.Channels) == 0 {
b.Edit("❌ Добавьте хотя бы один канал", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
// Проверяем существование каналов через API
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
notFoundChannels := s.validateChannels(b, jwt)
if len(notFoundChannels) > 0 {
text := "❌ Следующие каналы не найдены:\n\n"
for _, username := range notFoundChannels {
text += fmt.Sprintf("• @%s\n", username)
}
text += "\nУдалите их из списка и попробуйте снова"
b.Edit(text, Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
b.SetState(&PurchaseOptionalDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
BackState: s.BackState,
}, bot.EditMessage)
case "back_to_select":
s.Enter(b, 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) > 15 && data[:15] == "remove_channel:" {
var index int
if _, err := fmt.Sscanf(data[15:], "%d", &index); err == nil {
if index >= 0 && index < len(s.Channels) {
s.Channels = append(s.Channels[:index], s.Channels[index+1:]...)
if s.CurrentPage > 0 {
channelsPerPage := 6
pages := ui.CalculatePages(len(s.Channels), channelsPerPage)
if s.CurrentPage >= pages {
s.CurrentPage = pages - 1
}
}
s.Enter(b, bot.EditMessage)
return
}
}
}
s.Enter(b, bot.NewMessage)
}
}
func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
// Преобразуем каналы в формат API
var apiChannels []backend.CreatePurchaseChannelInput
for _, ch := range s.Channels {
var details *backend.PurchaseChannelDetails
if ch.PlannedCost != nil {
cost := backend.CostInfo{
Type: "fixed",
Value: *ch.PlannedCost,
}
details = &backend.PurchaseChannelDetails{
Cost: &cost,
}
}
apiChannels = append(apiChannels, backend.CreatePurchaseChannelInput{
Username: ch.Username,
Comment: ch.Comment,
Details: details,
})
}
input := backend.CreatePurchaseInput{
CreativeID: s.CreativeID,
Channels: apiChannels,
}
purchase, err := b.Backend.CreatePurchase(
context.Background(),
jwt,
s.WorkspaceID,
s.ProjectID,
input,
)
if err != nil {
log.Error().Err(err).Msg("Failed to create purchase")
b.Edit("❌ Не удалось создать закуп\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
b.SetState(&PurchaseDetails{
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
PurchaseID: purchase.ID,
BackState: s.BackState,
}, bot.EditMessage)
}
func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || u.Message.Text == "" {
return
}
raw := strings.TrimSpace(u.Message.Text)
if raw == "" {
return
}
tokens := strings.FieldsFunc(raw, func(r rune) bool {
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
})
added := 0
var invalid []string
for _, token := range tokens {
username := strings.TrimSpace(token)
username = strings.TrimPrefix(username, "@")
username = strings.Trim(username, "@")
username = strings.Trim(username, ",;")
if username == "" {
continue
}
if !channelUsernameRe.MatchString(username) {
invalid = append(invalid, username)
continue
}
exists := false
for _, ch := range s.Channels {
if ch.Username == username {
exists = true
break
}
}
if exists {
continue
}
s.Channels = append(s.Channels, PurchaseChannelInput{
Username: username,
})
added++
}
if added == 0 {
if len(invalid) == 0 {
return
}
}
s.InvalidUsernames = invalid
// Обновляем экран
s.Enter(b, bot.NewMessage)
}
func (s *SelectChannelsForPurchase) validateChannels(b *bot.Bot, jwt string) []string {
var notFound []string
for _, ch := range s.Channels {
channels, err := b.Backend.SearchChannels(
context.Background(),
jwt,
ch.Username,
)
if err != nil {
log.Error().Err(err).Str("username", ch.Username).Msg("Failed to search channel")
notFound = append(notFound, ch.Username)
continue
}
// Проверяем что нашли хотя бы один канал с точным совпадением username
found := false
for _, channel := range channels {
if channel.Username != nil && *channel.Username == ch.Username {
found = true
break
}
}
if !found {
notFound = append(notFound, ch.Username)
}
}
return notFound
}
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
// Обрабатываем callback после успешного создания
if u.CallbackQuery != nil && u.CallbackQuery.Data == "done" {
// Возвращаемся к списку закупов
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
}
func (s *SelectChannelsForPurchase) Exit() {}

View File

@@ -0,0 +1,151 @@
package screens
import (
"context"
"fmt"
"strings"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
"github.com/TelegramExchange/tgex-backend/tg_bot/screens/ui"
"github.com/rs/zerolog/log"
)
type SelectWorkspace struct {
ChannelID string
BackState bot.State
CurrentPage int
}
const msgChooseWorkspace = `
📁 Выбор workspace для канала
В какой workspace добавить канал?
`
const msgSuccessChooseWorkspace = `
✅ Канал успешно добавлен!
📊 Проект ID: %s
📝 Название: %s
🏷 Статус: %s
`
func (s *SelectWorkspace) Enter(b *bot.Bot, mode bot.RenderMode) {
workspaces, err := b.Backend.GetWorkspaces(context.Background(), b.Session.JWT)
if err != nil {
b.SendNew("❌ Не удалось загрузить список workspace'ов", Keyboard())
return
}
if len(workspaces) == 0 {
kb := Keyboard(Row(Button("Главное меню", "back")))
b.SendNew("У вас пока нет workspace'ов", kb)
return
}
// Создаем кнопки для всех workspace'ов
var allButtons []echotron.InlineKeyboardButton
for _, ws := range workspaces {
allButtons = append(allButtons, Button(ws.Name, fmt.Sprintf("select_workspace:%s", ws.ID)))
}
// Используем компонент пагинации для автоматической раскладки
const workspacesPerPage = 6
var buttons [][]echotron.InlineKeyboardButton
// Раскладываем workspace'ы в grid (2 в ряд) с пагинацией
elementRows := ui.BuildElementRows(ui.ElementLayoutConfig{
CurrentPage: s.CurrentPage,
ItemsPerPage: workspacesPerPage,
ItemsPerRow: 2,
}, allButtons)
buttons = append(buttons, elementRows...)
// Добавляем навигацию (стрелки появятся только если страниц > 1)
if navRow := ui.BuildNavigationRow(ui.PaginationConfig{
CurrentPage: s.CurrentPage,
TotalItems: len(allButtons),
ItemsPerPage: workspacesPerPage,
}); navRow != nil {
buttons = append(buttons, navRow)
}
// Кнопка отмены
buttons = append(buttons, Row(Button("Отмена", "cancel")))
kb := Keyboard(buttons...)
b.Render(msgChooseWorkspace, kb, mode)
}
func (s *SelectWorkspace) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
switch {
case data == "prev":
if s.CurrentPage > 0 {
s.CurrentPage--
}
s.Enter(b, bot.EditMessage)
case data == "next":
s.CurrentPage++
s.Enter(b, bot.EditMessage)
case data == "cancel", data == "back":
if s.BackState != nil {
b.SetState(s.BackState, bot.NewMessage)
}
case strings.HasPrefix(data, "select_workspace:"):
parts := strings.Split(data, ":")
if len(parts) != 2 {
log.Error().Str("callback_data", data).Msg("Invalid callback data format")
return
}
workspaceID := parts[1]
project, err := b.Backend.AttachChannelToWorkspace(
context.Background(),
s.ChannelID,
workspaceID,
b.ChatID,
)
if err != nil {
b.Edit("❌ Не удалось добавить канал в workspace", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
successText := fmt.Sprintf(msgSuccessChooseWorkspace, project.ID, project.Title, project.Status)
kb := Keyboard(
Row(Button("Мои проекты", "my_projects")),
Row(Button("Главное меню", "main_menu")),
)
b.Edit(successText, kb)
case data == "back_to_select":
s.Enter(b, bot.EditMessage)
case data == "my_projects":
b.SetState(&MyProjects{}, bot.NewMessage)
case data == "main_menu":
b.SetState(&MainMenu{}, bot.NewMessage)
default:
s.Enter(b, bot.NewMessage)
}
return
}
func (s *SelectWorkspace) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectWorkspace) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *SelectWorkspace) Exit() {}

View File

@@ -0,0 +1,829 @@
package ui
import (
"fmt"
"regexp"
"strings"
"time"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
const (
pickerViewCalendar = "calendar"
pickerViewMonths = "months"
pickerViewTimeCombined = "time_combined"
pickerViewConfirm = "confirm"
dtpCallbackPrefix = "dtp:"
)
var MskLocation = time.FixedZone("MSK", 3*60*60)
type DateTimePickerConfig struct {
Title string
Key string
IncludeTime bool
Selected *time.Time
BackState bot.State
}
type DateTimeSelectionTarget interface {
SetDateTimeSelection(key string, value time.Time)
}
type DateTimePicker struct {
Title string
Key string
IncludeTime bool
BackState bot.State
view string
year int
month time.Month
selectedDate *time.Time
selectedHour *int
selectedMinute *int
}
func NewDateTimePicker(cfg DateTimePickerConfig) *DateTimePicker {
now := time.Now().In(MskLocation)
year := now.Year()
month := now.Month()
var selected *time.Time
if cfg.Selected != nil {
value := cfg.Selected.In(MskLocation)
selected = &value
year = value.Year()
month = value.Month()
}
return &DateTimePicker{
Title: cfg.Title,
Key: cfg.Key,
IncludeTime: cfg.IncludeTime,
BackState: cfg.BackState,
view: pickerViewCalendar,
year: year,
month: month,
selectedDate: selected,
}
}
func (s *DateTimePicker) Enter(b *bot.Bot, mode bot.RenderMode) {
text := fmt.Sprintf("<b>%s</b>\n\n", s.Title)
text += fmt.Sprintf("%s\n\n", s.formatSelectedDateTime())
switch s.view {
case pickerViewCalendar:
text += s.renderCalendarTitle()
b.Render(text, s.calendarKeyboard(), mode)
case pickerViewMonths:
text += "Выберите месяц\n\n"
b.Render(text, s.monthsKeyboard(), mode)
case pickerViewTimeCombined:
b.Render(text, s.timeCombinedKeyboard(), mode)
case pickerViewConfirm:
text += s.renderSelectedDateTime()
b.Render(text, s.confirmKeyboard(), mode)
}
}
func (s *DateTimePicker) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return
}
data := u.CallbackQuery.Data
if data == "back" {
s.handleBack(b)
return
}
if data == "cancel" {
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
return
}
if !strings.HasPrefix(data, dtpCallbackPrefix) {
s.Enter(b, bot.EditMessage)
return
}
payload := strings.TrimPrefix(data, dtpCallbackPrefix)
switch {
case payload == "month_prev":
s.prevMonth()
case payload == "month_next":
s.nextMonth()
case payload == "open_months":
s.view = pickerViewMonths
case payload == "year_prev":
s.year--
case payload == "year_next":
s.year++
case strings.HasPrefix(payload, "month:"):
month := parseInt(strings.TrimPrefix(payload, "month:"))
if month >= 1 && month <= 12 {
s.month = time.Month(month)
s.view = pickerViewCalendar
}
case strings.HasPrefix(payload, "day:"):
s.handleDaySelect(payload)
case strings.HasPrefix(payload, "hour:"):
s.handleHourSelect(payload)
case strings.HasPrefix(payload, "min:"):
s.handleMinuteSelect(payload)
case payload == "confirm":
s.confirmSelection(b)
return
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) handleBack(b *bot.Bot) {
switch s.view {
case pickerViewMonths:
s.view = pickerViewCalendar
case pickerViewTimeCombined:
s.view = pickerViewCalendar
case pickerViewConfirm:
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewCalendar
}
default:
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
return
}
}
s.Enter(b, bot.EditMessage)
}
func (s *DateTimePicker) renderCalendarTitle() string {
if s.year == time.Now().In(MskLocation).Year() {
return "\n"
}
return fmt.Sprintf("%d\n\n", s.year)
}
func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, []echotron.InlineKeyboardButton{
s.monthPrevButton(),
{Text: monthName(s.month), CallbackData: dtpCallbackPrefix + "open_months"},
{Text: "→", CallbackData: dtpCallbackPrefix + "month_next"},
})
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "Пн", CallbackData: "empty"},
{Text: "Вт", CallbackData: "empty"},
{Text: "Ср", CallbackData: "empty"},
{Text: "Чт", CallbackData: "empty"},
{Text: "Пт", CallbackData: "empty"},
{Text: "Сб", CallbackData: "empty"},
{Text: "Вс", CallbackData: "empty"},
})
firstOfMonth := time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation)
weekday := int(firstOfMonth.Weekday())
if weekday == 0 {
weekday = 7
}
daysInMonth := daysInMonth(s.year, s.month)
today := time.Now().In(MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
var row []echotron.InlineKeyboardButton
for i := 1; i < weekday; i++ {
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
}
for day := 1; day <= daysInMonth; day++ {
date := time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation)
if date.Before(todayDate) {
row = append(row, echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"})
} else {
label := fmt.Sprintf("%d", day)
row = append(row, echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%sday:%04d-%02d-%02d", dtpCallbackPrefix, s.year, int(s.month), day),
})
}
if len(row) == 7 {
rows = append(rows, row)
row = nil
}
}
if len(row) > 0 {
for len(row) < 7 {
row = append(row, echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"})
}
rows = append(rows, row)
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
{Text: "Отмена", CallbackData: "cancel"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
for i := 0; i < 12; i += 3 {
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: months[i], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)},
{Text: months[i+1], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)},
{Text: months[i+2], CallbackData: fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)},
})
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) timeCombinedKeyboard() echotron.InlineKeyboardMarkup {
var rows [][]echotron.InlineKeyboardButton
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Часы", CallbackData: "empty"}})
for i := 0; i < 24; i += 6 {
rows = append(rows, []echotron.InlineKeyboardButton{
s.hourButton(i),
s.hourButton(i + 1),
s.hourButton(i + 2),
s.hourButton(i + 3),
s.hourButton(i + 4),
s.hourButton(i + 5),
})
}
rows = append(rows, []echotron.InlineKeyboardButton{{Text: "Минуты", CallbackData: "empty"}})
minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
for i := 0; i < len(minutes); i += 6 {
rows = append(rows, []echotron.InlineKeyboardButton{
s.minuteButton(minutes[i]),
s.minuteButton(minutes[i+1]),
s.minuteButton(minutes[i+2]),
s.minuteButton(minutes[i+3]),
s.minuteButton(minutes[i+4]),
s.minuteButton(minutes[i+5]),
})
}
rows = append(rows, []echotron.InlineKeyboardButton{
{Text: "← Назад", CallbackData: "back"},
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
})
return echotron.InlineKeyboardMarkup{InlineKeyboard: rows}
}
func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
return echotron.InlineKeyboardMarkup{
InlineKeyboard: [][]echotron.InlineKeyboardButton{
{
{Text: "← Назад", CallbackData: "back"},
{Text: "Готово", CallbackData: dtpCallbackPrefix + "confirm"},
},
},
}
}
func (s *DateTimePicker) renderSelectedDateTime() string {
if s.selectedDate == nil {
return "—"
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
return fmt.Sprintf("<b>%s</b>", formatCompactDateTime(value))
}
func (s *DateTimePicker) formatTimePreview(showPlaceholders bool) string {
hour := "__"
minute := "__"
if s.selectedHour != nil {
hour = fmt.Sprintf("%02d", *s.selectedHour)
} else if !showPlaceholders {
hour = "00"
}
if s.selectedMinute != nil {
minute = fmt.Sprintf("%02d", *s.selectedMinute)
} else if !showPlaceholders {
minute = "00"
}
return fmt.Sprintf("%s:%s", hour, minute)
}
func (s *DateTimePicker) formatSelectedDateTime() string {
displayDate, ok := s.displayDate()
if !ok {
return "—"
}
timeLabel := "--:--"
if s.selectedHour != nil && s.selectedMinute != nil {
timeLabel = fmt.Sprintf("%02d:%02d", *s.selectedHour, *s.selectedMinute)
}
weekday := WeekdayName(displayDate.Weekday())
month := MonthShort(displayDate.Month())
return fmt.Sprintf("%s %02d %s %s %dг.", weekday, displayDate.Day(), month, timeLabel, displayDate.Year())
}
func (s *DateTimePicker) displayDate() (time.Time, bool) {
if s.selectedDate == nil {
if s.view == pickerViewCalendar || s.view == pickerViewMonths {
return time.Date(s.year, s.month, 1, 0, 0, 0, 0, MskLocation), true
}
return time.Time{}, false
}
if (s.view == pickerViewCalendar || s.view == pickerViewMonths) &&
(s.selectedDate.Year() != s.year || s.selectedDate.Month() != s.month) {
day := s.selectedDate.Day()
maxDay := daysInMonth(s.year, s.month)
if day > maxDay {
day = maxDay
}
return time.Date(s.year, s.month, day, 0, 0, 0, 0, MskLocation), true
}
return s.selectedDate.In(MskLocation), true
}
func (s *DateTimePicker) handleDaySelect(payload string) {
datePart := strings.TrimPrefix(payload, "day:")
parsed, err := time.ParseInLocation("2006-01-02", datePart, MskLocation)
if err != nil {
return
}
s.selectedDate = &parsed
if s.IncludeTime {
s.view = pickerViewTimeCombined
s.selectedHour = nil
s.selectedMinute = nil
} else {
s.view = pickerViewConfirm
}
}
func (s *DateTimePicker) handleHourSelect(payload string) {
hour := parseInt(strings.TrimPrefix(payload, "hour:"))
if hour < 0 || hour > 23 {
return
}
if s.isHourDisabled(hour) {
return
}
s.selectedHour = &hour
}
func (s *DateTimePicker) handleMinuteSelect(payload string) {
minute := parseInt(strings.TrimPrefix(payload, "min:"))
if minute < 0 || minute > 59 {
return
}
if s.isMinuteDisabled(minute) {
return
}
s.selectedMinute = &minute
}
func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
if s.selectedDate == nil {
s.Enter(b, bot.EditMessage)
return
}
if s.IncludeTime && (s.selectedHour == nil || s.selectedMinute == nil) {
s.view = pickerViewTimeCombined
s.Enter(b, bot.EditMessage)
return
}
hour := 0
minute := 0
if s.selectedHour != nil {
hour = *s.selectedHour
}
if s.selectedMinute != nil {
minute = *s.selectedMinute
}
value := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), hour, minute, 0, 0, MskLocation)
if target, ok := s.BackState.(DateTimeSelectionTarget); ok {
target.SetDateTimeSelection(s.Key, value)
}
if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
}
func (s *DateTimePicker) hourButton(hour int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", hour)
if s.isHourDisabled(hour) {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
if s.selectedHour != nil && *s.selectedHour == hour {
label = "● " + label
}
return echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour),
}
}
func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
label := fmt.Sprintf("%02d", minute)
if s.isMinuteDisabled(minute) {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
if s.selectedMinute != nil && *s.selectedMinute == minute {
label = "● " + label
}
return echotron.InlineKeyboardButton{
Text: label,
CallbackData: fmt.Sprintf("%smin:%02d", dtpCallbackPrefix, minute),
}
}
func (s *DateTimePicker) isHourDisabled(hour int) bool {
if s.selectedDate == nil {
return false
}
today := time.Now().In(MskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
if date.After(todayDate) {
return false
}
return hour < today.Hour()
}
func (s *DateTimePicker) isMinuteDisabled(minute int) bool {
if s.selectedDate == nil || s.selectedHour == nil {
return false
}
today := time.Now().In(MskLocation)
date := time.Date(s.selectedDate.Year(), s.selectedDate.Month(), s.selectedDate.Day(), 0, 0, 0, 0, MskLocation)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, MskLocation)
if date.After(todayDate) {
return false
}
if *s.selectedHour > today.Hour() {
return false
}
return minute < today.Minute()
}
func (s *DateTimePicker) prevMonth() {
now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() {
return
}
if s.month == time.January {
s.month = time.December
s.year--
} else {
s.month--
}
}
func (s *DateTimePicker) nextMonth() {
if s.month == time.December {
s.month = time.January
s.year++
} else {
s.month++
}
}
func (s *DateTimePicker) monthPrevButton() echotron.InlineKeyboardButton {
now := time.Now().In(MskLocation)
if s.year == now.Year() && s.month == now.Month() {
return echotron.InlineKeyboardButton{Text: "-", CallbackData: "empty"}
}
return echotron.InlineKeyboardButton{Text: "←", CallbackData: dtpCallbackPrefix + "month_prev"}
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, MskLocation).Day()
}
func monthName(month time.Month) string {
names := []string{
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь",
"Июль", "Август", "Сентябрь", "Октябрь", "Ноябрь", "Декабрь",
}
if int(month) < 1 || int(month) > len(names) {
return ""
}
return names[int(month)-1]
}
func parseInt(value string) int {
result := 0
for _, r := range value {
if r < '0' || r > '9' {
return 0
}
result = result*10 + int(r-'0')
}
return result
}
func (s *DateTimePicker) HandleMessage(b *bot.Bot, u *echotron.Update) {
if u.Message == nil || strings.TrimSpace(u.Message.Text) == "" {
return
}
input := strings.TrimSpace(u.Message.Text)
switch s.view {
case pickerViewCalendar:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
if !hasDate {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
if hasTime {
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
}
if s.IncludeTime {
s.view = pickerViewTimeCombined
} else {
s.view = pickerViewConfirm
}
s.Enter(b, bot.NewMessage)
case pickerViewTimeCombined:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
if !hasTime {
s.Enter(b, bot.NewMessage)
return
}
if hasDate {
s.selectedDate = &value
}
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.Enter(b, bot.NewMessage)
default:
value, hasDate, hasTime := parseDateTimeInput(input, s.selectedDate, s.selectedHour, s.selectedMinute)
if !hasDate && !hasTime {
s.Enter(b, bot.NewMessage)
return
}
s.selectedDate = &value
hour := value.Hour()
minute := value.Minute()
s.selectedHour = &hour
s.selectedMinute = &minute
s.view = pickerViewConfirm
s.Enter(b, bot.NewMessage)
}
}
func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
func (s *DateTimePicker) Exit() {}
func parseDateTimeInput(
input string,
currentDate *time.Time,
currentHour *int,
currentMinute *int,
) (time.Time, bool, bool) {
text := strings.ToLower(strings.TrimSpace(input))
text = strings.ReplaceAll(text, ",", " ")
text = strings.ReplaceAll(text, " ", " ")
now := time.Now().In(MskLocation)
baseDate := now
if currentDate != nil {
baseDate = currentDate.In(MskLocation)
}
date, dateOk := parseDate(text, baseDate)
hour, minute, timeOk := parseTime(text, currentHour, currentMinute)
if !dateOk && !timeOk {
return time.Time{}, false, false
}
if !dateOk {
date = time.Date(baseDate.Year(), baseDate.Month(), baseDate.Day(), 0, 0, 0, 0, MskLocation)
}
if !timeOk {
hour = 0
minute = 0
}
value := time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, MskLocation)
if value.Before(now) {
return time.Time{}, false, false
}
return value, dateOk, timeOk
}
func parseDate(text string, baseDate time.Time) (time.Time, bool) {
if strings.Contains(text, "сегодня") {
return baseDate, true
}
if strings.Contains(text, "завтра") {
return baseDate.AddDate(0, 0, 1), true
}
if strings.Contains(text, "послезавтра") {
return baseDate.AddDate(0, 0, 2), true
}
if date := parseNumericDate(text); !date.IsZero() {
return date, true
}
if date := parseWordDate(text); !date.IsZero() {
return date, true
}
return time.Time{}, false
}
func parseNumericDate(text string) time.Time {
reYMD := regexp.MustCompile(`\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b`)
if match := reYMD.FindStringSubmatch(text); len(match) == 4 {
year := parseInt(match[1])
month := parseInt(match[2])
day := parseInt(match[3])
return safeDate(year, month, day)
}
reDMY := regexp.MustCompile(`\b(\d{1,2})[./-](\d{1,2})(?:[./-](\d{2,4}))?\b`)
if match := reDMY.FindStringSubmatch(text); len(match) >= 3 {
day := parseInt(match[1])
month := parseInt(match[2])
year := time.Now().In(MskLocation).Year()
if len(match) == 4 && match[3] != "" {
year = parseInt(match[3])
if year < 100 {
year += 2000
}
}
return safeDate(year, month, day)
}
return time.Time{}
}
func parseWordDate(text string) time.Time {
monthMap := map[string]time.Month{
"янв": 1, "январ": 1, "января": 1,
"фев": 2, "феврал": 2, "февраля": 2,
"мар": 3, "марта": 3,
"апр": 4, "апрел": 4, "апреля": 4,
"май": 5, "мая": 5,
"июн": 6, "июня": 6,
"июл": 7, "июля": 7,
"авг": 8, "август": 8, "августа": 8,
"сен": 9, "сент": 9, "сентябр": 9, "сентября": 9,
"окт": 10, "октябр": 10, "октября": 10,
"ноя": 11, "ноябр": 11, "ноября": 11,
"дек": 12, "декабр": 12, "декабря": 12,
}
re := regexp.MustCompile(`\b(\d{1,2})\s+([a-zа-яё]+)(?:\s+(\d{2,4}))?\b`)
match := re.FindStringSubmatch(text)
if len(match) == 0 {
return time.Time{}
}
day := parseInt(match[1])
monthToken := match[2]
var month time.Month
for key, value := range monthMap {
if strings.HasPrefix(monthToken, key) {
month = value
break
}
}
if month == 0 {
return time.Time{}
}
year := time.Now().In(MskLocation).Year()
if len(match) == 4 && match[3] != "" {
year = parseInt(match[3])
if year < 100 {
year += 2000
}
}
return safeDate(year, int(month), day)
}
func parseTime(text string, currentHour *int, currentMinute *int) (int, int, bool) {
re := regexp.MustCompile(`\b(\d{1,2})[:.](\d{2})\b`)
if match := re.FindStringSubmatch(text); len(match) == 3 {
hour := parseInt(match[1])
minute := parseInt(match[2])
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
return hour, minute, true
}
}
reCompact := regexp.MustCompile(`\b(\d{1,2})\s+(\d{2})\b`)
if match := reCompact.FindStringSubmatch(text); len(match) == 3 {
hour := parseInt(match[1])
minute := parseInt(match[2])
if hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59 {
return hour, minute, true
}
}
reHour := regexp.MustCompile(`\b(\d{1,2})\s*(?:ч|час|часов)\b`)
if match := reHour.FindStringSubmatch(text); len(match) == 2 {
hour := parseInt(match[1])
if hour >= 0 && hour <= 23 {
minute := 0
if currentMinute != nil {
minute = *currentMinute
}
return hour, minute, true
}
}
reJustHour := regexp.MustCompile(`\b(\d{1,2})\b`)
if match := reJustHour.FindStringSubmatch(text); len(match) == 2 && strings.Contains(text, "в") {
hour := parseInt(match[1])
if hour >= 0 && hour <= 23 {
minute := 0
if currentMinute != nil {
minute = *currentMinute
}
return hour, minute, true
}
}
if currentHour != nil && currentMinute != nil {
return *currentHour, *currentMinute, true
}
return 0, 0, false
}
func safeDate(year, month, day int) time.Time {
if month < 1 || month > 12 {
return time.Time{}
}
if day < 1 || day > 31 {
return time.Time{}
}
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, MskLocation)
}
func WeekdayName(day time.Weekday) string {
switch day {
case time.Monday:
return "Пн"
case time.Tuesday:
return "Вт"
case time.Wednesday:
return "Ср"
case time.Thursday:
return "Чт"
case time.Friday:
return "Пт"
case time.Saturday:
return "Сб"
case time.Sunday:
return "Вс"
default:
return ""
}
}
func formatCompactDateTime(value time.Time) string {
weekday := WeekdayName(value.Weekday())
month := MonthShort(value.Month())
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
}
func MonthShort(month time.Month) string {
months := []string{
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек",
}
if int(month) < 1 || int(month) > len(months) {
return ""
}
return months[int(month)-1]
}

View File

@@ -0,0 +1,304 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/NicoNex/echotron/v3"
)
type htmlTag struct {
open string
close string
}
var simpleHTMLTags = map[echotron.MessageEntityType]htmlTag{
echotron.BoldEntity: {open: "<b>", close: "</b>"},
echotron.ItalicEntity: {open: "<i>", close: "</i>"},
echotron.UnderlineEntity: {open: "<u>", close: "</u>"},
echotron.StrikethroughEntity: {open: "<s>", close: "</s>"},
echotron.CodeEntity: {open: "<code>", close: "</code>"},
echotron.PreEntity: {open: "<pre>", close: "</pre>"},
}
// FormatMessageHTML converts Telegram entities to HTML and escapes the rest.
// Invite links are normalized into <a class="tg-link"> placeholders for previews.
func FormatMessageHTML(message *echotron.Message) string {
if message == nil {
return ""
}
// Telegram sends formatting as entities, so we rebuild the HTML from offsets.
text := message.Text
entities := message.Entities
if text == "" {
text = message.Caption
entities = message.CaptionEntities
}
if text == "" {
return ""
}
if len(entities) == 0 {
return EscapeHTML(text)
}
type tagSpan struct {
open string
close string
start int
end int
len int
}
runes := []rune(text)
positions := make([]int, len(runes)+1)
utf16Count := 0
for i, r := range runes {
positions[i] = utf16Count
if r > 0xFFFF {
utf16Count += 2
} else {
utf16Count++
}
}
positions[len(runes)] = utf16Count
utf16ToRuneIndex := func(utf16Index int) (int, bool) {
i := sort.Search(len(positions), func(i int) bool { return positions[i] >= utf16Index })
if i < len(positions) && positions[i] == utf16Index {
return i, true
}
return 0, false
}
toRuneRange := func(offset, length int) (int, int, bool) {
start, ok := utf16ToRuneIndex(offset)
if !ok {
return 0, 0, false
}
end, ok := utf16ToRuneIndex(offset + length)
if !ok || end > len(runes) || end < start {
return 0, 0, false
}
return start, end, true
}
// URL ranges are used to prevent nested tags inside links.
urlRanges := make([][2]int, 0, len(entities))
for _, entity := range entities {
if entity == nil {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
urlRanges = append(urlRanges, [2]int{start, end})
if entity.Type == echotron.UrlEntity {
_ = string(runes[start:end])
}
}
isInsideURL := func(offset, length int) bool {
for _, urlRange := range urlRanges {
if offset > urlRange[0] && offset+length <= urlRange[1] {
return true
}
if offset < urlRange[1] && offset+length > urlRange[0] && (offset != urlRange[0] || length != urlRange[1]-urlRange[0]) {
return true
}
}
return false
}
var spans []tagSpan
for _, entity := range entities {
if entity == nil {
continue
}
start, end, ok := toRuneRange(entity.Offset, entity.Length)
if !ok {
continue
}
if entity.Type != echotron.UrlEntity && entity.Type != echotron.TextLinkEntity && isInsideURL(start, end-start) {
continue
}
var openTag, closeTag string
if tag, ok := simpleHTMLTags[entity.Type]; ok {
openTag, closeTag = tag.open, tag.close
} else if entity.Type == echotron.UrlEntity {
entityText := string(runes[start:end])
if isInviteLink(entityText) {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entityText)
closeTag = "</a>"
}
} else if entity.Type == echotron.TextLinkEntity {
if entity.URL != "" {
if isInviteLink(entity.URL) {
openTag = `<a class="tg-link">`
closeTag = "</a>"
} else {
openTag = fmt.Sprintf("<a href=\"%s\">", entity.URL)
closeTag = "</a>"
}
}
}
if openTag != "" && closeTag != "" {
spans = append(spans, tagSpan{
open: openTag,
close: closeTag,
start: start,
end: end,
len: end - start,
})
}
}
opens := make(map[int][]tagSpan)
closes := make(map[int][]tagSpan)
for _, span := range spans {
opens[span.start] = append(opens[span.start], span)
closes[span.end] = append(closes[span.end], span)
}
// Build the final text with tags inserted and HTML escaped.
var result strings.Builder
for i := 0; i <= len(runes); i++ {
if closing, ok := closes[i]; ok {
sort.Slice(closing, func(a, b int) bool { return closing[a].len < closing[b].len })
for _, span := range closing {
result.WriteString(span.close)
}
}
if opening, ok := opens[i]; ok {
sort.Slice(opening, func(a, b int) bool { return opening[a].len > opening[b].len })
for _, span := range opening {
result.WriteString(span.open)
}
}
if i < len(runes) {
switch runes[i] {
case '<':
result.WriteString("&lt;")
case '>':
result.WriteString("&gt;")
case '&':
result.WriteString("&amp;")
default:
result.WriteRune(runes[i])
}
}
}
return normalizeHTMLTags(result.String())
}
// isInviteLink detects Telegram invite links used in creatives.
func isInviteLink(url string) bool {
return strings.Contains(url, "t.me/+") || strings.Contains(url, "t.me/joinchat/")
}
func normalizeHTMLTags(input string) string {
if input == "" {
return input
}
allowed := map[string]bool{
"a": true,
"b": true,
"i": true,
"u": true,
"s": true,
"code": true,
"pre": true,
}
var out strings.Builder
out.Grow(len(input))
stack := make([]string, 0, 8)
for i := 0; i < len(input); {
if input[i] != '<' {
out.WriteByte(input[i])
i++
continue
}
end := strings.IndexByte(input[i:], '>')
if end == -1 {
out.WriteByte(input[i])
i++
continue
}
end += i
tag := input[i+1 : end]
if tag == "" {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
isClosing := tag[0] == '/'
tagName := tag
if isClosing {
tagName = tag[1:]
}
if space := strings.IndexByte(tagName, ' '); space != -1 {
tagName = tagName[:space]
}
tagName = strings.TrimSpace(tagName)
if !allowed[tagName] {
out.WriteString(input[i : end+1])
i = end + 1
continue
}
if isClosing {
if len(stack) > 0 && stack[len(stack)-1] == tagName {
stack = stack[:len(stack)-1]
out.WriteString(input[i : end+1])
}
} else {
stack = append(stack, tagName)
out.WriteString(input[i : end+1])
}
i = end + 1
}
for i := len(stack) - 1; i >= 0; i-- {
out.WriteString("</")
out.WriteString(stack[i])
out.WriteString(">")
}
return out.String()
}
// EscapeHTML escapes HTML special characters for safe output.
func EscapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
// SanitizeHTML normalizes tag nesting to prevent invalid HTML in Telegram parse mode.
func SanitizeHTML(input string) string {
return normalizeHTMLTags(input)
}

View File

@@ -0,0 +1,175 @@
package ui
import (
"fmt"
"github.com/NicoNex/echotron/v3"
)
// PaginationConfig конфигурация для построения навигационного ряда пагинации
type PaginationConfig struct {
CurrentPage int
TotalPages int // Для API пагинации (из page.Pages)
TotalItems int // Для локальной пагинации (len(array))
ItemsPerPage int // Для расчета TotalPages из TotalItems
PrevCallback string // По умолчанию "prev"
NextCallback string // По умолчанию "next"
MiddleButtons []echotron.InlineKeyboardButton // Опциональные кнопки в центре (например, " Добавить")
}
// BuildNavigationRow создает навигационный ряд с динамической шириной.
// Показывает ряд только если страниц > 1.
// Возвращает nil если пагинация не нужна.
func BuildNavigationRow(config PaginationConfig) []echotron.InlineKeyboardButton {
// Вычисляем TotalPages из TotalItems если не задан
totalPages := config.TotalPages
if totalPages == 0 && config.TotalItems > 0 && config.ItemsPerPage > 0 {
totalPages = CalculatePages(config.TotalItems, config.ItemsPerPage)
}
// Устанавливаем значения по умолчанию для callbacks
prevCallback := config.PrevCallback
if prevCallback == "" {
prevCallback = "prev"
}
nextCallback := config.NextCallback
if nextCallback == "" {
nextCallback = "next"
}
var row []echotron.InlineKeyboardButton
// Левая стрелка (только если есть предыдущая страница)
if totalPages > 1 && config.CurrentPage > 0 {
row = append(row, echotron.InlineKeyboardButton{
Text: "←",
CallbackData: prevCallback,
})
}
// Средние кнопки (если есть)
if len(config.MiddleButtons) > 0 {
row = append(row, config.MiddleButtons...)
}
// Правая стрелка (только если есть следующая страница)
if totalPages > 1 && config.CurrentPage+1 < totalPages {
row = append(row, echotron.InlineKeyboardButton{
Text: "→",
CallbackData: nextCallback,
})
}
if len(row) == 0 {
return nil
}
return row
}
// CalculatePages вычисляет количество страниц из общего количества элементов
func CalculatePages(totalItems, itemsPerPage int) int {
if itemsPerPage <= 0 {
return 0
}
return (totalItems + itemsPerPage - 1) / itemsPerPage
}
// GetPageBounds возвращает start/end индексы для текущей страницы.
// Автоматически сбрасывает на первую страницу если currentPage выходит за границы.
func GetPageBounds(currentPage, itemsPerPage, totalItems int) (start, end int) {
start = currentPage * itemsPerPage
if start >= totalItems {
start = 0
}
end = start + itemsPerPage
if end > totalItems {
end = totalItems
}
return start, end
}
// FormatPageInfo форматирует текст индикатора страницы для заголовка
func FormatPageInfo(currentPage, totalPages int) string {
if totalPages <= 1 {
return ""
}
return fmt.Sprintf(" <i>(стр. %d/%d)</i>", currentPage+1, totalPages)
}
// ElementLayoutConfig конфигурация для автоматической раскладки элементов с пагинацией
type ElementLayoutConfig struct {
CurrentPage int // Текущая страница (0-indexed)
ItemsPerPage int // Элементов на странице
ItemsPerRow int // Элементов в одном ряду
EmptyButton *echotron.InlineKeyboardButton // Кнопка-заполнитель (опционально, по умолчанию " ")
}
// BuildElementRows автоматически раскладывает элементы с пагинацией.
// Принимает все элементы, возвращает ряды для текущей страницы с правильной раскладкой.
// Автоматически заполняет пустыми кнопками если страниц > 1 (для консистентности высоты).
// Возвращает 2D массив кнопок готовый к добавлению в клавиатуру.
//
// Пример использования:
//
// config := ui.ElementLayoutConfig{
// CurrentPage: 0,
// ItemsPerPage: 6,
// ItemsPerRow: 2,
// }
// rows := ui.BuildElementRows(config, allButtons)
// keyboard = append(keyboard, rows...)
func BuildElementRows(config ElementLayoutConfig, allItems []echotron.InlineKeyboardButton) [][]echotron.InlineKeyboardButton {
if len(allItems) == 0 {
return nil
}
// Параметры по умолчанию
if config.ItemsPerPage <= 0 {
config.ItemsPerPage = 6
}
if config.ItemsPerRow <= 0 {
config.ItemsPerRow = 2
}
// Кнопка-заполнитель по умолчанию
emptyButton := echotron.InlineKeyboardButton{Text: " ", CallbackData: "empty"}
if config.EmptyButton != nil {
emptyButton = *config.EmptyButton
}
// Вычисляем границы страницы
totalPages := CalculatePages(len(allItems), config.ItemsPerPage)
start, end := GetPageBounds(config.CurrentPage, config.ItemsPerPage, len(allItems))
// Получаем элементы текущей страницы
pageItems := allItems[start:end]
// Если страниц >= 2, заполняем до ItemsPerPage пустыми кнопками
// Это обеспечивает одинаковую высоту клавиатуры на всех страницах
if totalPages >= 2 {
for len(pageItems) < config.ItemsPerPage {
pageItems = append(pageItems, emptyButton)
}
}
// Раскладываем элементы по рядам
var rows [][]echotron.InlineKeyboardButton
for i := 0; i < len(pageItems); i += config.ItemsPerRow {
var row []echotron.InlineKeyboardButton
// Добавляем элементы в ряд
for j := 0; j < config.ItemsPerRow && i+j < len(pageItems); j++ {
row = append(row, pageItems[i+j])
}
// Если в ряду меньше элементов чем ItemsPerRow, заполняем пустыми
for len(row) < config.ItemsPerRow {
row = append(row, emptyButton)
}
rows = append(rows, row)
}
return rows
}