diff --git a/telegram_bot/adapter/backend/backend_client.go b/telegram_bot/adapter/backend/backend_client.go
index 2e79bcb..5fc9207 100644
--- a/telegram_bot/adapter/backend/backend_client.go
+++ b/telegram_bot/adapter/backend/backend_client.go
@@ -306,14 +306,22 @@ func (c *Client) AcceptWorkspaceInvite(
// ============================================================================
type Creative struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Text string `json:"text"`
- ProjectID string `json:"project_id"`
- ProjectChannelTitle string `json:"project_channel_title"`
- CreatedAt string `json:"created_at"`
- Status string `json:"status"`
- PlacementsCount int `json:"placements_count"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Text string `json:"text"`
+ MediaType string `json:"media_type"`
+ MediaFileID string `json:"media_file_id"`
+ Buttons []CreativeButton `json:"buttons"`
+ ProjectID string `json:"project_id"`
+ ProjectChannelTitle string `json:"project_channel_title"`
+ CreatedAt string `json:"created_at"`
+ Status string `json:"status"`
+ PlacementsCount int `json:"placements_count"`
+}
+
+type CreativeButton struct {
+ Text string `json:"text"`
+ URL string `json:"url"`
}
type CreativesPage struct {
@@ -377,8 +385,12 @@ func (c *Client) GetCreative(
}
type CreateCreativeInput struct {
- Name string `json:"name"`
- Text string `json:"text"`
+ Name string `json:"name"`
+ Text string `json:"text"`
+ MediaType string `json:"media_type,omitempty"`
+ MediaFileID string `json:"media_file_id,omitempty"`
+ MediaData []byte `json:"media_data,omitempty"`
+ Buttons []CreativeButton `json:"buttons,omitempty"`
}
func (c *Client) CreateCreative(
@@ -407,9 +419,13 @@ func (c *Client) CreateCreative(
}
type UpdateCreativeInput struct {
- Name *string `json:"name,omitempty"`
- Text *string `json:"text,omitempty"`
- Status *string `json:"status,omitempty"`
+ Name *string `json:"name,omitempty"`
+ Text *string `json:"text,omitempty"`
+ MediaType *string `json:"media_type,omitempty"`
+ MediaFileID *string `json:"media_file_id,omitempty"`
+ MediaData *[]byte `json:"media_data,omitempty"`
+ Buttons *[]CreativeButton `json:"buttons,omitempty"`
+ Status *string `json:"status,omitempty"`
}
func (c *Client) UpdateCreative(
diff --git a/telegram_bot/bot/bot.go b/telegram_bot/bot/bot.go
index cda95ac..17e5f06 100644
--- a/telegram_bot/bot/bot.go
+++ b/telegram_bot/bot/bot.go
@@ -103,6 +103,17 @@ func (b *Bot) Render(text string, keyboard echotron.InlineKeyboardMarkup, mode R
}
}
+func (b *Bot) DownloadFileBytes(fileID string) ([]byte, error) {
+ res, err := b.GetFile(fileID)
+ if err != nil {
+ return nil, err
+ }
+ if res.Result == nil || res.Result.FilePath == "" {
+ return nil, fmt.Errorf("telegram file not available")
+ }
+ return b.DownloadFile(res.Result.FilePath)
+}
+
func (b *Bot) SendNew(text string, keyboard echotron.InlineKeyboardMarkup) {
// Удаляем кнопки у предыдущего сообщения перед отправкой нового
if b.LastMessageID != 0 {
diff --git a/telegram_bot/screens/add_creative.go b/telegram_bot/screens/add_creative.go
index 47013f7..61cd6ca 100644
--- a/telegram_bot/screens/add_creative.go
+++ b/telegram_bot/screens/add_creative.go
@@ -97,6 +97,7 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{
// Удаляем медиа
s.MediaType = ""
s.MediaFileID = ""
+ s.MediaChanged = true
s.UpdateCreativePreview(b)
s.ShowManagementPanel(b, "✓ Сохранить", "confirm_create", "cancel")
@@ -170,14 +171,17 @@ func (s *AddCreative) HandleMessage(b *bot.Bot, u *echotron.Update) {
photo := u.Message.Photo[len(u.Message.Photo)-1]
s.MediaType = "photo"
s.MediaFileID = photo.FileID
+ s.MediaChanged = true
handled = true
} else if u.Message.Video != nil {
s.MediaType = "video"
s.MediaFileID = u.Message.Video.FileID
+ s.MediaChanged = true
handled = true
} else if u.Message.Animation != nil {
s.MediaType = "animation"
s.MediaFileID = u.Message.Animation.FileID
+ s.MediaChanged = true
handled = true
}
@@ -307,6 +311,26 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
return
}
+ 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")
+ b.SendNew("❌ Не удалось загрузить медиа", bot.Keyboard(
+ bot.Row(bot.Button("← Назад", "cancel")),
+ ))
+ 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,
+ })
+ }
+
// Создаём креатив через API
creative, err := b.Backend.CreateCreative(
context.Background(),
@@ -314,8 +338,12 @@ func (s *AddCreative) createCreative(b *bot.Bot) {
s.WorkspaceID,
s.ProjectID,
backend.CreateCreativeInput{
- Name: *s.Name,
- Text: *s.Text,
+ Name: *s.Name,
+ Text: *s.Text,
+ MediaType: s.MediaType,
+ MediaFileID: s.MediaFileID,
+ MediaData: mediaData,
+ Buttons: buttons,
},
)
diff --git a/telegram_bot/screens/add_purchase.go b/telegram_bot/screens/add_purchase.go
index 048af95..0af80a0 100644
--- a/telegram_bot/screens/add_purchase.go
+++ b/telegram_bot/screens/add_purchase.go
@@ -10,13 +10,20 @@ import (
)
const addPurchaseCreativesPerPage = 5
+const addPurchaseProjectsPerPage = 6
type AddPurchase struct {
- WorkspaceID string
- ProjectID string
- ProjectTitle string
- CurrentPage int
- BackState bot.State
+ 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) {
@@ -27,73 +34,159 @@ func (s *AddPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
return
}
- s.renderCreativeSelection(b, mode, jwt)
+ s.renderSelection(b, mode, jwt)
}
-func (s *AddPurchase) renderCreativeSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
- // Получаем список активных креативов
- page, err := b.Backend.GetCreatives(
- context.Background(),
- jwt,
- s.WorkspaceID,
- &s.ProjectID,
- false, // только активные
- s.CurrentPage+1,
- addPurchaseCreativesPerPage,
- )
- if err != nil {
- log.Error().Err(err).Msg("Failed to get creatives")
- b.SendNew("❌ Не удалось загрузить креативы", bot.Keyboard())
- return
- }
-
+func (s *AddPurchase) renderSelection(b *bot.Bot, mode bot.RenderMode, jwt string) {
text := "… › Закупы › Создание\n\nСоздание закупа\n\n"
+ text += "Выберите проект и креатив.\n\n"
var buttons [][]echotron.InlineKeyboardButton
- if len(page.Items) == 0 {
- text += `Для создания закупа сначала нужно создать креатив.
+ 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, bot.Row(
+ bot.Button(projectLabel, "pick_project"),
+ ))
+ buttons = append(buttons, bot.Row(
+ bot.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("❌ Не удалось загрузить проекты", bot.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 {
+ pageInfo := ""
+ if page.Pages > 1 {
+ pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
+ }
+
+ text += fmt.Sprintf("Проекты%s\n\n", pageInfo)
+ for _, project := range page.Items {
+ buttons = append(buttons, bot.Row(
+ bot.Button(project.Title, fmt.Sprintf("project:%s", project.ID)),
+ ))
+ }
+
+ var navRow []echotron.InlineKeyboardButton
+ if s.ProjectPage > 0 {
+ navRow = append(navRow, bot.Button("←", "prev_project"))
+ }
+ if s.ProjectPage+1 < page.Pages {
+ navRow = append(navRow, bot.Button("→", "next_project"))
+ }
+ if len(navRow) > 0 {
+ buttons = append(buttons, navRow)
+ }
+ }
+
+ buttons = append(buttons, bot.Row(
+ bot.Button("← Назад", "back_to_selection"),
+ ))
+
+ case "creative_list":
+ if s.ProjectID == "" {
+ text += "Сначала выберите проект, чтобы увидеть список креативов.\n\n"
+ buttons = append(buttons, bot.Row(
+ bot.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("❌ Не удалось загрузить креативы", bot.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 += `Для создания закупа сначала нужно создать креатив.
Вернитесь назад и создайте креатив в разделе Креативы`
+ } else {
+ pageInfo := ""
+ if page.Pages > 1 {
+ pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
+ }
- buttons = append(buttons, bot.Row(
- bot.Button("← Назад", "back"),
- ))
- } else {
- pageInfo := ""
- if page.Pages > 1 {
- pageInfo = fmt.Sprintf(" (стр. %d/%d)", page.Page, page.Pages)
+ text += fmt.Sprintf("Креативы%s\n\n", pageInfo)
+
+ for _, creative := range page.Items {
+ buttons = append(buttons, bot.Row(
+ bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
+ ))
+ }
+
+ var navRow []echotron.InlineKeyboardButton
+ if s.CreativePage > 0 {
+ navRow = append(navRow, bot.Button("←", "prev_creative"))
+ }
+ if s.CreativePage+1 < page.Pages {
+ navRow = append(navRow, bot.Button("→", "next_creative"))
+ }
+ if len(navRow) > 0 {
+ buttons = append(buttons, navRow)
+ }
}
- text += fmt.Sprintf(`Шаг 1/2: Выбор креатива%s
+ buttons = append(buttons, bot.Row(
+ bot.Button("← Назад", "back_to_selection"),
+ ))
+ }
-Выберите креатив для закупа
-
-`, pageInfo)
-
- // Кнопки с креативами
- for _, creative := range page.Items {
+ if s.ActivePicker == "" {
+ if s.ProjectID != "" && s.CreativeID != "" {
buttons = append(buttons, bot.Row(
- bot.Button(creative.Name, fmt.Sprintf("creative:%s", creative.ID)),
+ bot.Button("← Отмена", "back"),
+ bot.Button("→ Далее", "next"),
+ ))
+ } else {
+ buttons = append(buttons, bot.Row(
+ bot.Button("← Отмена", "back"),
))
}
-
- // Пагинация
- var navRow []echotron.InlineKeyboardButton
- if s.CurrentPage > 0 {
- navRow = append(navRow, bot.Button("←", "prev"))
- }
- if s.CurrentPage+1 < page.Pages {
- navRow = append(navRow, bot.Button("→", "next"))
- }
- if len(navRow) > 0 {
- buttons = append(buttons, navRow)
- }
-
- // Кнопка назад
- buttons = append(buttons, bot.Row(
- bot.Button("← Отмена", "back"),
- ))
}
keyboard := bot.Keyboard(buttons...)
@@ -127,30 +220,74 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
b.SetState(s.BackState, bot.EditMessage)
}
- case "prev":
- if s.CurrentPage > 0 {
- s.CurrentPage--
+ 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_project":
+ if s.ProjectPage > 0 {
+ s.ProjectPage--
}
s.Enter(b, bot.EditMessage)
- case "next":
- s.CurrentPage++
+ case "next_project":
+ s.ProjectPage++
s.Enter(b, bot.EditMessage)
+ case "prev_creative":
+ if s.CreativePage > 0 {
+ s.CreativePage--
+ }
+ s.Enter(b, bot.EditMessage)
+
+ case "next_creative":
+ s.CreativePage++
+ s.Enter(b, bot.EditMessage)
+
+ case "next":
+ 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:]
-
- // Переходим к выбору каналов
- b.SetState(&SelectChannelsForPurchase{
- WorkspaceID: s.WorkspaceID,
- ProjectID: s.ProjectID,
- ProjectTitle: s.ProjectTitle,
- CreativeID: creativeID,
- Channels: []PurchaseChannelInput{},
- BackState: s.BackState,
- }, bot.EditMessage)
+ 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)
}
diff --git a/telegram_bot/screens/creative_details.go b/telegram_bot/screens/creative_details.go
index 4f87534..75c2c49 100644
--- a/telegram_bot/screens/creative_details.go
+++ b/telegram_bot/screens/creative_details.go
@@ -50,7 +50,13 @@ func (s *CreativeDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
} else {
log.Info().Msg("Creative text is empty from backend")
}
- // Buttons, MediaType, MediaFileID остаются пустыми (не сохраняются в БД)
+ 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.Navigation = "… › Креативы › Детали"
@@ -121,6 +127,7 @@ https://t.me/+AbCdEfGhIjKlMn`, [][]echotron.InlineKeyboardButton{
case data == "delete_media":
s.MediaType = ""
s.MediaFileID = ""
+ s.MediaChanged = true
s.UpdateCreativePreview(b)
s.showManagementPanelWithDelete(b)
@@ -193,6 +200,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
largestPhoto := photos[len(photos)-1]
s.MediaType = "photo"
s.MediaFileID = largestPhoto.FileID
+ s.MediaChanged = true
s.WaitingForMedia = false
s.UpdateCreativePreview(b)
@@ -202,6 +210,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
} else if u.Message.Video != nil {
s.MediaType = "video"
s.MediaFileID = u.Message.Video.FileID
+ s.MediaChanged = true
s.WaitingForMedia = false
s.UpdateCreativePreview(b)
@@ -211,6 +220,7 @@ func (s *CreativeDetails) HandleMessage(b *bot.Bot, u *echotron.Update) {
} else if u.Message.Animation != nil {
s.MediaType = "animation"
s.MediaFileID = u.Message.Animation.FileID
+ s.MediaChanged = true
s.WaitingForMedia = false
s.UpdateCreativePreview(b)
@@ -325,6 +335,36 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
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")
+ b.SendNew("❌ Не удалось загрузить медиа", bot.Keyboard(
+ bot.Row(bot.Button("← Назад", "back")),
+ ))
+ 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(
diff --git a/telegram_bot/screens/creative_editor.go b/telegram_bot/screens/creative_editor.go
index 09928f6..3a2ff77 100644
--- a/telegram_bot/screens/creative_editor.go
+++ b/telegram_bot/screens/creative_editor.go
@@ -24,8 +24,9 @@ type CreativeEditorFields struct {
Buttons []InlineButton
// Медиа
- MediaType string // "photo", "video", "animation"
- MediaFileID string // File ID из Telegram
+ MediaType string // "photo", "video", "animation"
+ MediaFileID string // File ID из Telegram
+ MediaChanged bool
// Навигация (хлебные крошки)
Navigation string
diff --git a/telegram_bot/screens/date_time_picker.go b/telegram_bot/screens/date_time_picker.go
new file mode 100644
index 0000000..00901c7
--- /dev/null
+++ b/telegram_bot/screens/date_time_picker.go
@@ -0,0 +1,501 @@
+package screens
+
+import (
+ "fmt"
+ "strings"
+ "time"
+
+ "example.com/m/bot"
+ "github.com/NicoNex/echotron/v3"
+)
+
+const (
+ pickerViewCalendar = "calendar"
+ pickerViewMonths = "months"
+ pickerViewTimeHour = "time_hour"
+ pickerViewTimeMin = "time_minute"
+ 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("… › %s\n\n%s\n\n", s.Title, s.Title)
+ 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 pickerViewTimeHour:
+ text += fmt.Sprintf("Выбрано: %s\n\n", s.formatTimePreview(false))
+ text += "Выберите час\n\n"
+ b.Render(text, s.timeHourKeyboard(), mode)
+ case pickerViewTimeMin:
+ text += fmt.Sprintf("Выбрано: %s\n\n", s.formatTimePreview(false))
+ text += "Выберите минуты\n\n"
+ b.Render(text, s.timeMinuteKeyboard(), 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 pickerViewTimeHour:
+ s.view = pickerViewCalendar
+ case pickerViewTimeMin:
+ s.view = pickerViewTimeHour
+ case pickerViewConfirm:
+ if s.IncludeTime {
+ s.view = pickerViewTimeMin
+ } 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 {
+ header := monthName(s.month)
+ if s.selectedDate != nil {
+ header = fmt.Sprintf("%s\nВыбрано: %s", header, s.selectedDate.In(mskLocation).Format("02.01.2006"))
+ }
+ return header + "\n\n"
+}
+
+func (s *DateTimePicker) calendarKeyboard() echotron.InlineKeyboardMarkup {
+ var rows [][]echotron.InlineKeyboardButton
+
+ rows = append(rows, bot.Row(
+ bot.Button("«", dtpCallbackPrefix+"year_prev"),
+ bot.Button(fmt.Sprintf("%d", s.year), "empty"),
+ bot.Button("»", dtpCallbackPrefix+"year_next"),
+ ))
+ rows = append(rows, bot.Row(
+ bot.Button("←", dtpCallbackPrefix+"month_prev"),
+ bot.Button(monthName(s.month), dtpCallbackPrefix+"open_months"),
+ bot.Button("→", dtpCallbackPrefix+"month_next"),
+ ))
+
+ rows = append(rows, bot.Row(
+ bot.Button("Пн", "empty"),
+ bot.Button("Вт", "empty"),
+ bot.Button("Ср", "empty"),
+ bot.Button("Чт", "empty"),
+ bot.Button("Пт", "empty"),
+ bot.Button("Сб", "empty"),
+ bot.Button("Вс", "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, bot.Button(" ", "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, bot.Button("-", "empty"))
+ } else {
+ label := fmt.Sprintf("%d", day)
+ row = append(row, bot.Button(label, 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, bot.Button(" ", "empty"))
+ }
+ rows = append(rows, row)
+ }
+
+ rows = append(rows, bot.Row(
+ bot.Button("← Назад", "back"),
+ bot.Button("Отмена", "cancel"),
+ ))
+
+ return bot.Keyboard(rows...)
+}
+
+func (s *DateTimePicker) monthsKeyboard() echotron.InlineKeyboardMarkup {
+ var rows [][]echotron.InlineKeyboardButton
+ rows = append(rows, bot.Row(
+ bot.Button("«", dtpCallbackPrefix+"year_prev"),
+ bot.Button(fmt.Sprintf("%d", s.year), "empty"),
+ bot.Button("»", dtpCallbackPrefix+"year_next"),
+ ))
+
+ months := []string{"Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"}
+ for i := 0; i < 12; i += 3 {
+ rows = append(rows, bot.Row(
+ bot.Button(months[i], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+1)),
+ bot.Button(months[i+1], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+2)),
+ bot.Button(months[i+2], fmt.Sprintf("%smonth:%d", dtpCallbackPrefix, i+3)),
+ ))
+ }
+
+ rows = append(rows, bot.Row(
+ bot.Button("← Назад", "back"),
+ ))
+
+ return bot.Keyboard(rows...)
+}
+
+func (s *DateTimePicker) timeHourKeyboard() echotron.InlineKeyboardMarkup {
+ var rows [][]echotron.InlineKeyboardButton
+ for i := 0; i < 24; i += 4 {
+ rows = append(rows, bot.Row(
+ s.hourButton(i),
+ s.hourButton(i+1),
+ s.hourButton(i+2),
+ s.hourButton(i+3),
+ ))
+ }
+ rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
+ return bot.Keyboard(rows...)
+}
+
+func (s *DateTimePicker) timeMinuteKeyboard() echotron.InlineKeyboardMarkup {
+ var rows [][]echotron.InlineKeyboardButton
+ minutes := []int{0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}
+ for i := 0; i < len(minutes); i += 4 {
+ rows = append(rows, bot.Row(
+ s.minuteButton(minutes[i]),
+ s.minuteButton(minutes[i+1]),
+ s.minuteButton(minutes[i+2]),
+ s.minuteButton(minutes[i+3]),
+ ))
+ }
+ rows = append(rows, bot.Row(bot.Button("← Назад", "back")))
+ return bot.Keyboard(rows...)
+}
+
+func (s *DateTimePicker) confirmKeyboard() echotron.InlineKeyboardMarkup {
+ return bot.Keyboard(
+ bot.Row(
+ bot.Button("← Назад", "back"),
+ bot.Button("Готово", 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("Выбрано: %s\n\nНажмите «Готово»", value.Format("02.01.2006 15:04"))
+}
+
+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) 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 = pickerViewTimeHour
+ 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
+ s.view = pickerViewTimeMin
+}
+
+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
+ s.view = pickerViewConfirm
+}
+
+func (s *DateTimePicker) confirmSelection(b *bot.Bot) {
+ 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)
+ 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 bot.Button("-", "empty")
+ }
+ if s.selectedHour != nil && *s.selectedHour == hour {
+ label = "● " + label
+ }
+ return bot.Button(label, fmt.Sprintf("%shour:%02d", dtpCallbackPrefix, hour))
+}
+
+func (s *DateTimePicker) minuteButton(minute int) echotron.InlineKeyboardButton {
+ label := fmt.Sprintf("%02d", minute)
+ if s.isMinuteDisabled(minute) {
+ return bot.Button("-", "empty")
+ }
+ if s.selectedMinute != nil && *s.selectedMinute == minute {
+ label = "● " + label
+ }
+ return bot.Button(label, 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() {
+ 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 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(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *DateTimePicker) Handle(_ *bot.Bot, _ *echotron.Update) { return }
diff --git a/telegram_bot/screens/purchase_optional_details.go b/telegram_bot/screens/purchase_optional_details.go
new file mode 100644
index 0000000..dcdbdc3
--- /dev/null
+++ b/telegram_bot/screens/purchase_optional_details.go
@@ -0,0 +1,108 @@
+package screens
+
+import (
+ "fmt"
+ "time"
+
+ "example.com/m/bot"
+ "github.com/NicoNex/echotron/v3"
+)
+
+type PurchaseOptionalDetails struct {
+ WorkspaceID string
+ ProjectID string
+ ProjectTitle string
+ CreativeID string
+ CreativeTitle string
+ Channels []PurchaseChannelInput
+ PlacementDateTime *time.Time
+ PaymentDate *time.Time
+ BackState bot.State
+}
+
+func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
+ text := "… › Создание › Дополнительно\n\n"
+ text += "Страница создания закупа и необязательные составляющие"
+
+ placementLabel := "☑ Дата и время размещения"
+ if s.PlacementDateTime != nil {
+ placementLabel = fmt.Sprintf("✅ Дата и время размещения: %s", s.PlacementDateTime.In(mskLocation).Format("02.01 15:04"))
+ }
+ paymentLabel := "Дата оплаты"
+ if s.PaymentDate != nil {
+ paymentLabel = fmt.Sprintf("✅ Дата оплаты: %s", s.PaymentDate.In(mskLocation).Format("02.01 15:04"))
+ }
+
+ keyboard := bot.Keyboard(
+ bot.Row(
+ bot.Button(placementLabel, "opt_datetime"),
+ bot.Button("☑ Стоимость: [цена)", "opt_cost"),
+ ),
+ bot.Row(
+ bot.Button(paymentLabel, "opt_payment_date"),
+ bot.Button("Тип закупа [вп / стандарт]", "opt_type"),
+ ),
+ bot.Row(
+ bot.Button("Формат: [формат)", "opt_format"),
+ bot.Button("Комментарий", "opt_comment"),
+ ),
+ bot.Row(
+ bot.Button("Назад", "back"),
+ bot.Button("Далее", "next"),
+ ),
+ )
+
+ b.Render(text, keyboard, mode)
+}
+
+func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
+ if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
+ return
+ }
+
+ switch u.CallbackQuery.Data {
+ case "opt_datetime":
+ b.SetState(NewDateTimePicker(DateTimePickerConfig{
+ Title: "Дата и время размещения",
+ Key: "placement_datetime",
+ IncludeTime: true,
+ Selected: s.PlacementDateTime,
+ BackState: s,
+ }), bot.EditMessage)
+
+ case "opt_payment_date":
+ b.SetState(NewDateTimePicker(DateTimePickerConfig{
+ Title: "Дата оплаты",
+ Key: "payment_date",
+ IncludeTime: true,
+ Selected: s.PaymentDate,
+ BackState: s,
+ }), bot.EditMessage)
+
+ case "back":
+ b.SetState(&SelectChannelsForPurchase{
+ WorkspaceID: s.WorkspaceID,
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ CreativeID: s.CreativeID,
+ CreativeTitle: s.CreativeTitle,
+ Channels: s.Channels,
+ BackState: s.BackState,
+ }, bot.EditMessage)
+ default:
+ s.Enter(b, bot.EditMessage)
+ }
+}
+
+func (s *PurchaseOptionalDetails) HandleMessage(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *PurchaseOptionalDetails) Handle(_ *bot.Bot, _ *echotron.Update) { return }
+
+func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Time) {
+ switch key {
+ case "placement_datetime":
+ s.PlacementDateTime = &value
+ case "payment_date":
+ s.PaymentDate = &value
+ }
+}
diff --git a/telegram_bot/screens/purchases.go b/telegram_bot/screens/purchases.go
index e9fd615..a516417 100644
--- a/telegram_bot/screens/purchases.go
+++ b/telegram_bot/screens/purchases.go
@@ -141,6 +141,7 @@ func (s *Purchases) HandleCallback(b *bot.Bot, u *echotron.Update) {
WorkspaceID: s.WorkspaceID,
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
+ ActivePicker: "",
BackState: s,
}, bot.EditMessage)
diff --git a/telegram_bot/screens/select_channels_for_purchase.go b/telegram_bot/screens/select_channels_for_purchase.go
index c0a5c13..93bb92d 100644
--- a/telegram_bot/screens/select_channels_for_purchase.go
+++ b/telegram_bot/screens/select_channels_for_purchase.go
@@ -3,6 +3,7 @@ package screens
import (
"context"
"fmt"
+ "regexp"
"strings"
"example.com/m/adapter/backend"
@@ -18,14 +19,19 @@ type PurchaseChannelInput struct {
}
type SelectChannelsForPurchase struct {
- WorkspaceID string
- ProjectID string
- ProjectTitle string
- CreativeID string
- Channels []PurchaseChannelInput
- BackState bot.State
+ 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)
}
@@ -43,7 +49,7 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
Отправьте username канала (без @)
Например: channel_name
-После добавления всех каналов нажмите Создать закуп`
+После добавления всех каналов нажмите Далее`
} else {
text += fmt.Sprintf("Добавлено каналов: %d\n\n", len(s.Channels))
@@ -54,19 +60,83 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
}
text += channelText + "\n"
}
+ text += "\n"
+
+ const channelsPerPage = 6
+ start := s.CurrentPage * channelsPerPage
+ if start >= len(s.Channels) {
+ s.CurrentPage = 0
+ start = 0
+ }
+ end := start + channelsPerPage
+ if end > len(s.Channels) {
+ end = 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, bot.Button(buttonText, fmt.Sprintf("remove_channel:%d", i)))
+ }
+
+ if len(s.Channels) > channelsPerPage {
+ for len(slots) < channelsPerPage {
+ slots = append(slots, bot.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, bot.Button(" ", "empty"))
+ }
+ buttons = append(buttons, row)
+ }
+
+ pages := (len(s.Channels) + channelsPerPage - 1) / channelsPerPage
+ if pages > 1 {
+ var navRow []echotron.InlineKeyboardButton
+ if s.CurrentPage > 0 {
+ navRow = append(navRow, bot.Button("←", "prev_page"))
+ } else {
+ navRow = append(navRow, bot.Button("·", "empty"))
+ }
+ navRow = append(navRow, bot.Button(fmt.Sprintf("• %d/%d •", s.CurrentPage+1, pages), "page_info"))
+ if s.CurrentPage+1 < pages {
+ navRow = append(navRow, bot.Button("→", "next_page"))
+ } else {
+ navRow = append(navRow, bot.Button("·", "empty"))
+ }
+ buttons = append(buttons, navRow)
+ }
text += "\nДобавьте еще каналы или создайте закуп"
// Кнопка создания закупа (доступна только если есть каналы)
- buttons = append(buttons, bot.Row(
- bot.Button("● Создать закуп", "create"),
- ))
+ }
+
+ if len(s.InvalidUsernames) > 0 {
+ text += fmt.Sprintf("\n\n⚠️ Пропущены: %s", strings.Join(s.InvalidUsernames, ", "))
+ s.InvalidUsernames = nil
}
// Кнопки навигации
- buttons = append(buttons, bot.Row(
- bot.Button("← Назад", "back"),
- ))
+ if len(s.Channels) > 0 {
+ buttons = append(buttons, bot.Row(
+ bot.Button("← Назад", "back"),
+ bot.Button("→ Далее", "next"),
+ ))
+ } else {
+ buttons = append(buttons, bot.Row(
+ bot.Button("← Назад", "back"),
+ ))
+ }
keyboard := bot.Keyboard(buttons...)
b.Render(text, keyboard, mode)
@@ -97,13 +167,16 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
case "back":
// Возвращаемся к выбору креатива
b.SetState(&AddPurchase{
- WorkspaceID: s.WorkspaceID,
- ProjectID: s.ProjectID,
- ProjectTitle: s.ProjectTitle,
- BackState: s.BackState,
+ WorkspaceID: s.WorkspaceID,
+ ProjectID: s.ProjectID,
+ ProjectTitle: s.ProjectTitle,
+ CreativeID: s.CreativeID,
+ CreativeTitle: s.CreativeTitle,
+ ActivePicker: "",
+ BackState: s.BackState,
}, bot.EditMessage)
- case "create":
+ case "next":
if len(s.Channels) == 0 {
b.Edit("❌ Добавьте хотя бы один канал", bot.Keyboard(
bot.Row(bot.Button("← Назад", "back_to_select")),
@@ -111,20 +184,47 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return
}
- jwt, err := s.ensureJWT(b)
- if err != nil {
- log.Error().Err(err).Msg("Failed to get JWT")
- b.Edit("❌ Ошибка авторизации", bot.Keyboard())
- return
- }
-
- // Создаем закуп
- s.createPurchase(b, jwt)
+ 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_page":
+ if s.CurrentPage > 0 {
+ s.CurrentPage--
+ }
+ s.Enter(b, bot.EditMessage)
+
+ case "next_page":
+ 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 := (len(s.Channels) + channelsPerPage - 1) / channelsPerPage
+ if s.CurrentPage >= pages {
+ s.CurrentPage = pages - 1
+ }
+ }
+ s.Enter(b, bot.EditMessage)
+ return
+ }
+ }
+ }
s.Enter(b, bot.NewMessage)
}
}
@@ -182,27 +282,55 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
return
}
- username := strings.TrimSpace(u.Message.Text)
-
- // Убираем @ если пользователь его добавил
- username = strings.TrimPrefix(username, "@")
-
- if username == "" {
+ raw := strings.TrimSpace(u.Message.Text)
+ if raw == "" {
return
}
- // Проверяем, не добавлен ли уже этот канал
- for _, ch := range s.Channels {
- if ch.Username == username {
- b.SendNew(fmt.Sprintf("⚠️ Канал @%s уже добавлен в список", username), bot.Keyboard())
+ 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.Channels = append(s.Channels, PurchaseChannelInput{
- Username: username,
- })
+ s.InvalidUsernames = invalid
// Обновляем экран
s.Enter(b, bot.NewMessage)