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 {
ChannelID string
Username string
Title string
InviteLink string
PlannedCost *float64
Comment *string
}
type SelectChannelsForPurchase struct {
ProjectID string
ProjectTitle string
ProjectTelegramID int64
ProjectUsername string
ProjectStatus 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 := "Создание закупа\n\n"
text += "Шаг 2/2: Добавление каналов\n\n"
var buttons [][]echotron.InlineKeyboardButton
if len(s.Channels) == 0 {
text += `Добавьте каналы для размещения рекламы
Отправьте username (без @) или invite link приватного канала
Например: channel_name или https://t.me/+abcdef
После добавления всех каналов нажмите Далее`
} else {
text += fmt.Sprintf("Добавлено каналов: %d\n\n", len(s.Channels))
for i, ch := range s.Channels {
channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch))
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 := channelLabel(ch)
if ch.PlannedCost != nil {
buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *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{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
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
}
failed := s.resolveChannels(b, jwt)
if len(failed) > 0 {
text := "❌ Не удалось добавить каналы:\n\n"
for _, label := range failed {
text += fmt.Sprintf("• %s\n", label)
}
text += "\nУдалите их из списка и попробуйте снова"
b.Edit(text, Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
b.SetState(&PurchaseOptionalDetails{
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.CreatePlacementChannelInput
for _, ch := range s.Channels {
var details *backend.PlacementDetails
if ch.PlannedCost != nil {
cost := backend.CostInfo{
Type: "fixed",
Value: *ch.PlannedCost,
}
details = &backend.PlacementDetails{
Cost: &cost,
}
}
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
ChannelID: ch.ChannelID,
Comment: ch.Comment,
Details: details,
})
}
input := backend.CreatePlacementsInput{
CreativeID: &s.CreativeID,
Channels: apiChannels,
}
placements, err := b.Backend.CreatePlacements(context.Background(), jwt, b.Session.WorkspaceID, s.ProjectID, input)
if err != nil {
log.Error().Err(err).Msg("Failed to create placements")
b.Edit("❌ Не удалось создать размещения\n\nВозможные причины:\n• Один из каналов не найден\n• Ошибка сервера", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
if len(placements.Placements) == 0 {
b.Edit("❌ Размещения не созданы", Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
b.SetState(&PlacementDetails{
ProjectID: s.ProjectID,
PlacementID: placements.Placements[0].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 {
entry := strings.TrimSpace(token)
entry = strings.Trim(entry, ",;")
if entry == "" {
continue
}
var username string
var inviteLink string
if isInviteLink(entry) {
inviteLink = entry
} else {
username = strings.TrimPrefix(entry, "@")
username = strings.Trim(username, "@")
if !channelUsernameRe.MatchString(username) {
invalid = append(invalid, entry)
continue
}
}
exists := false
for _, ch := range s.Channels {
if inviteLink != "" && ch.InviteLink == inviteLink {
exists = true
break
}
if username != "" && ch.Username == username {
exists = true
break
}
}
if exists {
continue
}
s.Channels = append(s.Channels, PurchaseChannelInput{
Username: username,
InviteLink: inviteLink,
})
added++
}
if added == 0 {
if len(invalid) == 0 {
return
}
}
s.InvalidUsernames = invalid
// Обновляем экран
s.Enter(b, bot.NewMessage)
}
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
for _, ch := range s.Channels {
if ch.InviteLink != "" {
link := ch.InviteLink
inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
} else {
username := ch.Username
inputs = append(inputs, backend.CreateChannelInput{Username: &username})
}
}
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
Channels: inputs,
})
if err != nil {
log.Error().Err(err).Msg("Failed to create channels")
return []string{"ошибка сервера"}
}
var failed []string
for _, result := range resp.Results {
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
if result.Index >= 0 && result.Index < len(s.Channels) {
failed = append(failed, channelLabel(s.Channels[result.Index]))
}
continue
}
if result.Index < 0 || result.Index >= len(s.Channels) {
continue
}
ch := &s.Channels[result.Index]
ch.ChannelID = result.Channel.ID
if result.Channel.Username != nil {
ch.Username = *result.Channel.Username
}
if result.Channel.Title != nil {
ch.Title = *result.Channel.Title
}
}
return failed
}
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() {}
func channelLabel(ch PurchaseChannelInput) string {
if ch.Username != "" {
return "@" + ch.Username
}
if ch.Title != "" {
return ch.Title
}
if ch.InviteLink != "" {
return "invite link"
}
if ch.ChannelID != "" {
return ch.ChannelID
}
return "канал"
}
func channelKey(ch PurchaseChannelInput) string {
if ch.ChannelID != "" {
return ch.ChannelID
}
if ch.Username != "" {
return ch.Username
}
if ch.InviteLink != "" {
return ch.InviteLink
}
return ""
}
func channelLabelByKey(channels []PurchaseChannelInput, key string) string {
for _, ch := range channels {
if channelKey(ch) == key {
return channelLabel(ch)
}
}
return key
}
func isInviteLink(value string) bool {
value = strings.TrimSpace(value)
if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") {
return true
}
if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") {
return true
}
return false
}