правки

This commit is contained in:
Artem Tsyrulnikov
2026-02-01 17:57:46 +03:00
parent a92f33c22f
commit 4e35c73a66
15 changed files with 1573 additions and 231 deletions

View File

@@ -3,7 +3,6 @@ package screens
import (
"context"
"fmt"
"regexp"
"strings"
"github.com/NicoNex/echotron/v3"
@@ -33,10 +32,16 @@ type SelectChannelsForPurchase struct {
Channels []PurchaseChannelInput
CurrentPage int
InvalidUsernames []string
Duplicates []string // дубликаты каналов
ParsingErrors []ParseError // ошибки парсинга с предложениями
BackState bot.State
}
var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
// ParseError представляет ошибку парсинга с предложением исправления
type ParseError struct {
Input string
Suggestion string
}
func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) {
s.renderChannelSelection(b, mode)
@@ -118,6 +123,30 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
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(
@@ -164,18 +193,18 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return
}
// Создаем/обновляем каналы в БД через API
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard())
return
// Проверяем, что все каналы резолвлены (имеют ChannelID)
var unresolved []string
for _, ch := range s.Channels {
if ch.ChannelID == "" {
label := channelLabel(ch)
unresolved = append(unresolved, label)
}
}
failed := s.resolveChannels(b, jwt)
if len(failed) > 0 {
text := "❌ Не удалось добавить каналы:\n\n"
for _, label := range failed {
if len(unresolved) > 0 {
text := "❌ Некоторые каналы не были найдены:\n\n"
for _, label := range unresolved {
text += fmt.Sprintf("• %s\n", label)
}
text += "\nУдалите их из списка и попробуйте снова"
@@ -186,6 +215,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return
}
// Каналы уже резолвлены в HandleMessage, переходим к следующему шагу
b.SetState(&PurchaseOptionalDetails{
ProjectID: s.ProjectID,
ProjectTitle: s.ProjectTitle,
@@ -290,12 +320,25 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
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
var invalid []string
s.Duplicates = nil
s.InvalidUsernames = nil
s.ParsingErrors = nil
tokensLoop:
for _, token := range tokens {
entry := strings.TrimSpace(token)
entry = strings.Trim(entry, ",;")
@@ -303,53 +346,144 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
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
}
}
// Используем новый парсер
parsed := ParseChannelInput(entry)
exists := false
for _, ch := range s.Channels {
if inviteLink != "" && ch.InviteLink == inviteLink {
exists = true
break
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)
}
if username != "" && ch.Username == username {
exists = true
break
}
}
if exists {
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{
Username: username,
InviteLink: inviteLink,
ChannelID: channel.ID,
Username: parsed.Username,
Title: title,
InviteLink: parsed.InviteLink,
})
added++
}
if added == 0 {
if len(invalid) == 0 {
return
}
// Если ничего не добавлено и нет ошибок - не обновляем экран
if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 {
return
}
s.InvalidUsernames = invalid
// Обновляем экран
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 {