правки
This commit is contained in:
@@ -271,6 +271,8 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||
CreativeID: s.CreativeID,
|
||||
CreativeTitle: s.CreativeTitle,
|
||||
Channels: []PurchaseChannelInput{},
|
||||
Duplicates: []string{},
|
||||
ParsingErrors: []ParseError{},
|
||||
BackState: s.BackState,
|
||||
}, bot.EditMessage)
|
||||
}
|
||||
|
||||
380
tg_bot/screens/channel_parser.go
Normal file
380
tg_bot/screens/channel_parser.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package screens
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ChannelInput представляет результат парсинга ввода пользователя
|
||||
type ChannelInput struct {
|
||||
Input string // оригинальный ввод пользователя
|
||||
Username string // извлеченный username (если применимо)
|
||||
InviteLink string // извлеченный invite link (если применимо)
|
||||
Source string // источник: telegram, tgstat, telemetr
|
||||
Type string // тип: username, invite, deeplink, stat
|
||||
Valid bool // валидный ли формат
|
||||
}
|
||||
|
||||
// Парсеры для разных форматов
|
||||
var (
|
||||
// Username без @: 5-32 символа, начинается с буквы
|
||||
channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
||||
|
||||
// Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx
|
||||
inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`)
|
||||
inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`)
|
||||
|
||||
// TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username
|
||||
tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{4,31})`)
|
||||
|
||||
// Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username
|
||||
telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{4,31})`)
|
||||
)
|
||||
|
||||
// ParseChannelInput парсит ввод пользователя и определяет тип канала
|
||||
func ParseChannelInput(input string) ChannelInput {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return ChannelInput{Valid: false}
|
||||
}
|
||||
|
||||
result := ChannelInput{Input: input}
|
||||
|
||||
// 1. Проверяем Telegram deeplink (tg://...)
|
||||
if strings.HasPrefix(input, "tg://") {
|
||||
return parseTelegramDeeplink(input)
|
||||
}
|
||||
|
||||
// 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx)
|
||||
if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) {
|
||||
result.Type = "invite"
|
||||
result.Source = "telegram"
|
||||
result.InviteLink = normalizeInviteLink(input)
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/)
|
||||
if isURLFormat(input) {
|
||||
return parseURLFormat(input)
|
||||
}
|
||||
|
||||
// 4. Проверяем @username
|
||||
if strings.HasPrefix(input, "@") {
|
||||
username := strings.TrimPrefix(input, "@")
|
||||
username = strings.TrimSpace(username)
|
||||
if channelUsernameRe.MatchString(username) {
|
||||
result.Username = username
|
||||
result.Type = "username"
|
||||
result.Source = "telegram"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// 5. Проверяем username (без @)
|
||||
if channelUsernameRe.MatchString(input) {
|
||||
result.Username = input
|
||||
result.Type = "username"
|
||||
result.Source = "telegram"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// 6. Ничего не подошло
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// parseTelegramDeeplink парсит tg:// ссылки
|
||||
func parseTelegramDeeplink(input string) ChannelInput {
|
||||
result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"}
|
||||
|
||||
// tg://resolve?domain=username&post=123
|
||||
if strings.HasPrefix(input, "tg://resolve?domain=") {
|
||||
parsed, err := url.Parse(input)
|
||||
if err != nil {
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
query := parsed.Query()
|
||||
domain := query.Get("domain")
|
||||
if domain != "" && channelUsernameRe.MatchString(domain) {
|
||||
result.Username = domain
|
||||
result.Type = "username"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// tg://join?invite=abcdef
|
||||
if strings.HasPrefix(input, "tg://join?invite=") {
|
||||
parsed, err := url.Parse(input)
|
||||
if err != nil {
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
query := parsed.Query()
|
||||
invite := query.Get("invite")
|
||||
if invite != "" {
|
||||
result.InviteLink = "t.me/+" + invite
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// isURLFormat проверяет, является ли ввод URL или ссылкой
|
||||
func isURLFormat(input string) bool {
|
||||
return strings.HasPrefix(input, "http://") ||
|
||||
strings.HasPrefix(input, "https://") ||
|
||||
strings.HasPrefix(input, "t.me/") ||
|
||||
strings.HasPrefix(input, "telegram.me/") ||
|
||||
strings.Contains(input, "t.me/") ||
|
||||
strings.Contains(input, "telegram.me/")
|
||||
}
|
||||
|
||||
// parseURLFormat парсит URL-форматы ссылок
|
||||
func parseURLFormat(input string) ChannelInput {
|
||||
result := ChannelInput{Input: input}
|
||||
|
||||
// Нормализуем: убираем протокол
|
||||
normalized := input
|
||||
normalized = strings.TrimPrefix(normalized, "https://")
|
||||
normalized = strings.TrimPrefix(normalized, "http://")
|
||||
normalized = strings.TrimPrefix(normalized, "www.")
|
||||
|
||||
// Проверяем TGStat
|
||||
if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||
username := strings.TrimSuffix(matches[1], "_")
|
||||
result.Username = username
|
||||
result.Type = "stat"
|
||||
result.Source = "tgstat"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Проверяем Telemetr
|
||||
if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 {
|
||||
username := strings.TrimSuffix(matches[1], "_")
|
||||
result.Username = username
|
||||
result.Type = "stat"
|
||||
result.Source = "telemetr"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Проверяем telegram.me или t.me
|
||||
if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") {
|
||||
return parseTelegramLink(normalized)
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// parseTelegramLink парсит ссылки t.me/... и telegram.me/...
|
||||
func parseTelegramLink(link string) ChannelInput {
|
||||
result := ChannelInput{Source: "telegram"}
|
||||
|
||||
// Убираем t.me/ или telegram.me/
|
||||
path := link
|
||||
if strings.HasPrefix(path, "telegram.me/") {
|
||||
path = strings.TrimPrefix(path, "telegram.me/")
|
||||
} else {
|
||||
path = strings.TrimPrefix(path, "t.me/")
|
||||
}
|
||||
|
||||
// Проверяем invite link: +xxxxx или joinchat/xxxxx
|
||||
if strings.HasPrefix(path, "+") {
|
||||
inviteHash := strings.TrimPrefix(path, "+")
|
||||
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||
if inviteHash != "" {
|
||||
result.InviteLink = "t.me/+" + inviteHash
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "joinchat/") {
|
||||
inviteHash := strings.TrimPrefix(path, "joinchat/")
|
||||
inviteHash = extractBeforeChar(inviteHash, '?', '/')
|
||||
if inviteHash != "" {
|
||||
result.InviteLink = "t.me/joinchat/" + inviteHash
|
||||
result.Type = "invite"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Извлекаем username из t.me/username или t.me/username/12345
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) > 0 {
|
||||
username := parts[0]
|
||||
// Убираем @ если есть
|
||||
username = strings.TrimPrefix(username, "@")
|
||||
|
||||
// Валидируем username
|
||||
if channelUsernameRe.MatchString(username) {
|
||||
result.Username = username
|
||||
result.Type = "username"
|
||||
result.Valid = true
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
result.Valid = false
|
||||
return result
|
||||
}
|
||||
|
||||
// normalizeInviteLink нормализует invite ссылку
|
||||
func normalizeInviteLink(link string) string {
|
||||
link = strings.TrimSpace(link)
|
||||
|
||||
// Добавляем протокол если нужно
|
||||
if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") {
|
||||
if strings.HasPrefix(link, "t.me/") {
|
||||
// Уже в нужном формате
|
||||
return link
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализуем t.me/+xxxxx
|
||||
if strings.HasPrefix(link, "t.me/+") {
|
||||
return link
|
||||
}
|
||||
|
||||
// Нормализуем t.me/joinchat/xxxxx
|
||||
if strings.HasPrefix(link, "t.me/joinchat/") {
|
||||
return link
|
||||
}
|
||||
|
||||
return link
|
||||
}
|
||||
|
||||
// extractBeforeChar извлекает часть строки до первого встреченного символа
|
||||
func extractBeforeChar(s string, chars ...rune) string {
|
||||
for _, c := range chars {
|
||||
if idx := strings.IndexRune(s, c); idx != -1 {
|
||||
return s[:idx]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SuggestFix предлагает исправление для некорректного ввода
|
||||
func SuggestFix(input string) (string, bool) {
|
||||
input = strings.TrimSpace(input)
|
||||
if input == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// 1. Исправляем опечатки в протоколе
|
||||
if strings.HasPrefix(input, "htp://") {
|
||||
fixed := strings.Replace(input, "htp://", "https://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(input, "ttps://") {
|
||||
fixed := strings.Replace(input, "ttps://", "https://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(input, "ttp://") {
|
||||
fixed := strings.Replace(input, "ttp://", "http://", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Исправляем tme/ → t.me/
|
||||
if strings.HasPrefix(input, "tme/") {
|
||||
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
if strings.Contains(input, "tme/") {
|
||||
fixed := strings.Replace(input, "tme/", "t.me/", 1)
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Исправляем @@ или @@
|
||||
if strings.HasPrefix(input, "@@") {
|
||||
fixed := strings.TrimPrefix(input, "@")
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Пробуем добавить @ в начало username
|
||||
if !strings.HasPrefix(input, "@") && !isURLFormat(input) {
|
||||
fixed := "@" + input
|
||||
if parsed := ParseChannelInput(fixed); parsed.Valid {
|
||||
return fixed, true
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Пробуем убрать лишние символы в конце
|
||||
cleaned := input
|
||||
cleaned = strings.TrimSuffix(cleaned, ".")
|
||||
cleaned = strings.TrimSuffix(cleaned, ",")
|
||||
cleaned = strings.TrimSuffix(cleaned, ";")
|
||||
cleaned = strings.TrimSpace(cleaned)
|
||||
if cleaned != input {
|
||||
if parsed := ParseChannelInput(cleaned); parsed.Valid {
|
||||
return cleaned, true
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Пробуем извлечь username из сложной ссылки
|
||||
if strings.Contains(input, "/") {
|
||||
parts := strings.Split(input, "/")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
part = strings.TrimPrefix(part, "@")
|
||||
if channelUsernameRe.MatchString(part) {
|
||||
return "@" + part, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
// FormatChannelLabel форматирует метку канала для отображения
|
||||
func FormatChannelLabel(input ChannelInput) string {
|
||||
if input.Username != "" {
|
||||
return "@" + input.Username
|
||||
}
|
||||
if input.InviteLink != "" {
|
||||
return formatInviteLabel(input.InviteLink)
|
||||
}
|
||||
return input.Input
|
||||
}
|
||||
|
||||
// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username)
|
||||
func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool {
|
||||
for _, ch := range channels {
|
||||
// Case-insensitive сравнение username (@MyChannel == @mychannel)
|
||||
if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) {
|
||||
return true
|
||||
}
|
||||
if input.InviteLink != "" && ch.InviteLink == input.InviteLink {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -54,7 +54,10 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
|
||||
)
|
||||
}
|
||||
if placement.Details.CostBeforeBargain != nil {
|
||||
text += fmt.Sprintf("• <b>До торга:</b> %.0f₽\n", *placement.Details.CostBeforeBargain)
|
||||
text += fmt.Sprintf("• <b>До торга:</b> %s %.0f₽\n",
|
||||
formatCostType(placement.Details.CostBeforeBargain.Type),
|
||||
placement.Details.CostBeforeBargain.Value,
|
||||
)
|
||||
}
|
||||
if placement.Details.Format != nil && *placement.Details.Format != "" {
|
||||
text += fmt.Sprintf("• <b>Формат:</b> %s\n", *placement.Details.Format)
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *Placements) Exit() {}
|
||||
|
||||
// PlacementsList - экран со списком размещений
|
||||
const placementsPerPage = 6
|
||||
const placementsPerRow = 2
|
||||
const placementsPerRow = 1
|
||||
|
||||
type PlacementsList struct {
|
||||
ProjectID string
|
||||
@@ -152,9 +152,9 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) {
|
||||
}
|
||||
|
||||
// Текст кнопки: #N • Канал (с пробелом и разделителем)
|
||||
buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName)
|
||||
buttonText := fmt.Sprintf("%d • %s", globalNum, channelName)
|
||||
if dateStr != "" {
|
||||
buttonText += fmt.Sprintf("\n%s", dateStr)
|
||||
buttonText += fmt.Sprintf(" %s", dateStr)
|
||||
}
|
||||
|
||||
buttons = append(buttons, echotron.InlineKeyboardButton{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user