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)