правки

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

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