381 lines
11 KiB
Go
381 lines
11 KiB
Go
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
|
||
}
|