Files
tgex-backend/tg_bot/screens/select_channels_for_purchase.go
Artem Tsyrulnikov 09145e510a fix
2026-02-03 22:21:08 +03:00

642 lines
18 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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
ProjectDefaultLinkType string
CreativeID string
CreativeTitle string
Channels []PurchaseChannelInput
CurrentPage int
InvalidUsernames []string
Duplicates []string // дубликаты каналов
ParsingErrors []ParseError // ошибки парсинга с предложениями
OptionalDetailsState *PurchaseOptionalDetails // сохранённое состояние деталей
BackState bot.State
}
// ParseError представляет ошибку парсинга с предложением исправления
type ParseError struct {
Input string
Suggestion string
}
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 (без @) или invite link приватного канала
Например: <code>channel_name</code> или <code>https://t.me/+abcdef</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, 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.Duplicates) > 0 {
text += fmt.Sprintf("\n\n⏭ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", "))
s.Duplicates = nil
}
// Показываем ошибки с предложениями
if len(s.ParsingErrors) > 0 {
text += "\n\n❌ Ошибки форматирования:\n"
for i, err := range s.ParsingErrors {
if i < 3 { // Показываем максимум 3 ошибки
if err.Suggestion != "" {
text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion)
} else {
text += fmt.Sprintf("• %s\n", err.Input)
}
}
}
if len(s.ParsingErrors) > 3 {
text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3)
}
s.ParsingErrors = 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
}
// Проверяем, что все каналы резолвлены (имеют ChannelID)
var unresolved []string
for _, ch := range s.Channels {
if ch.ChannelID == "" {
label := channelLabel(ch)
unresolved = append(unresolved, label)
}
}
if len(unresolved) > 0 {
text := "❌ Некоторые каналы не были найдены:\n\n"
for _, label := range unresolved {
text += fmt.Sprintf("• %s\n", label)
}
text += "\nУдалите их из списка и попробуйте снова"
b.Edit(text, Keyboard(
Row(Button("← Назад", "back_to_select")),
))
return
}
// Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
// Если есть сохранённое состояние - используем его, иначе создаём новое
var optionalDetails *PurchaseOptionalDetails
if s.OptionalDetailsState != nil {
optionalDetails = s.OptionalDetailsState
// Обновляем каналы на случай если они изменились
optionalDetails.Channels = s.Channels
} else {
optionalDetails = &PurchaseOptionalDetails{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
ProjectDefaultLinkType: s.ProjectDefaultLinkType,
CreativeID: s.CreativeID,
CreativeTitle: s.CreativeTitle,
Channels: s.Channels,
BackState: s,
}
}
b.SetState(optionalDetails, 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
}
// Проверяем JWT
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
}
// Разбиваем ввод на токены
tokens := strings.FieldsFunc(raw, func(r rune) bool {
return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';'
})
added := 0
s.Duplicates = nil
s.InvalidUsernames = nil
s.ParsingErrors = nil
tokensLoop:
for _, token := range tokens {
entry := strings.TrimSpace(token)
entry = strings.Trim(entry, ",;")
if entry == "" {
continue
}
// Используем новый парсер
parsed := ParseChannelInput(entry)
if !parsed.Valid {
// Пробуем предложить исправление
if suggestion, ok := SuggestFix(entry); ok {
s.ParsingErrors = append(s.ParsingErrors, ParseError{
Input: entry,
Suggestion: suggestion,
})
} else {
s.InvalidUsernames = append(s.InvalidUsernames, entry)
}
continue
}
// Проверяем дубликаты по username/invite link
if IsDuplicate(parsed, s.Channels) {
label := FormatChannelLabel(parsed)
s.Duplicates = append(s.Duplicates, label)
continue
}
// Сразу резолвим канал через бэкенд
channel, err := s.resolveSingleChannel(b, jwt, parsed)
if err != nil {
// Канал не найден или ошибка
s.InvalidUsernames = append(s.InvalidUsernames, entry)
log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel")
continue
}
// Проверяем дубликаты по ID канала (после резолва)
for _, ch := range s.Channels {
if ch.ChannelID != "" && ch.ChannelID == channel.ID {
var title, username string
if channel.Title != nil {
title = *channel.Title
}
if channel.Username != nil {
username = *channel.Username
}
label := channelLabelByID(&channel.ID, &title, &username)
s.Duplicates = append(s.Duplicates, label)
continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего!
}
// Дополнительная проверка по username (case-insensitive)
// На случай, если канал еще не резолвлен или ID отличается
if channel.Username != nil && ch.Username != "" {
if strings.EqualFold(ch.Username, *channel.Username) {
var title string
if channel.Title != nil {
title = *channel.Title
}
label := channelLabelByID(&channel.ID, &title, channel.Username)
s.Duplicates = append(s.Duplicates, label)
continue tokensLoop
}
}
}
// Добавляем канал с полными данными
var title string
if channel.Title != nil {
title = *channel.Title
}
s.Channels = append(s.Channels, PurchaseChannelInput{
ChannelID: channel.ID,
Username: parsed.Username,
Title: title,
InviteLink: parsed.InviteLink,
})
added++
}
// Если ничего не добавлено и нет ошибок - не обновляем экран
if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
return
}
// Обновляем экран
s.Enter(b, bot.NewMessage)
}
// resolveSingleChannel резолвит один канал через бэкенд
func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) {
var input backend.CreateChannelInput
if parsed.InviteLink != "" {
link := parsed.InviteLink
input = backend.CreateChannelInput{InviteLink: &link}
} else {
username := parsed.Username
input = backend.CreateChannelInput{Username: &username}
}
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
Channels: []backend.CreateChannelInput{input},
})
if err != nil {
return nil, err
}
if len(resp.Results) == 0 {
return nil, fmt.Errorf("no response from backend")
}
result := resp.Results[0]
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
errMsg := "channel not found"
if result.Error != nil {
errMsg = *result.Error
}
return nil, fmt.Errorf(errMsg)
}
// Если пришел username из ответа, обновляем его
if result.Channel.Username != nil && *result.Channel.Username != "" {
parsed.Username = *result.Channel.Username
}
return result.Channel, nil
}
// channelLabelByID формирует метку канала по ID
func channelLabelByID(id, title, username *string) string {
if title != nil && *title != "" {
return *title
}
if username != nil && *username != "" {
return "@" + *username
}
if id != nil && *id != "" {
return *id
}
return "канал"
}
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 formatInviteLabel(ch.InviteLink)
}
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
}
func formatInviteLabel(inviteLink string) string {
link := strings.TrimSpace(inviteLink)
if link == "" {
return "приватный канал"
}
code := link
if strings.HasPrefix(code, "tg://") || strings.HasPrefix(code, "tg:") {
if idx := strings.Index(code, "invite="); idx != -1 {
code = code[idx+len("invite="):]
if end := strings.IndexAny(code, "&?#"); end != -1 {
code = code[:end]
}
}
} else {
code = strings.TrimPrefix(code, "https://")
code = strings.TrimPrefix(code, "http://")
code = strings.TrimPrefix(code, "t.me/")
code = strings.TrimPrefix(code, "telegram.me/")
code = strings.TrimPrefix(code, "joinchat/")
code = strings.TrimPrefix(code, "+")
if idx := strings.LastIndex(code, "/"); idx != -1 {
code = code[idx+1:]
}
if end := strings.IndexAny(code, "?#"); end != -1 {
code = code[:end]
}
}
code = strings.Trim(code, "/+ ")
if code == "" {
return "приватный канал"
}
if len(code) > 10 {
code = code[:6] + "..." + code[len(code)-2:]
}
return "приватный: " + code
}