правки

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

@@ -581,7 +581,7 @@ type PlacementDetails struct {
PlacementAt *string `json:"placement_at,omitempty"`
PaymentAt *string `json:"payment_at,omitempty"`
Cost *CostInfo `json:"cost,omitempty"`
CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"`
CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"`
PlacementType *string `json:"placement_type,omitempty"`
Format *string `json:"format,omitempty"`
Comment *string `json:"comment,omitempty"`

View File

@@ -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)
}

View 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
}

View File

@@ -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)

View File

@@ -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

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 {