Files
tgex-backend/tg_bot/screens/channel_parser.go
Artem Tsyrulnikov e48cd7bccc мини фиксы
2026-02-18 20:21:38 +03:00

381 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 без @: 4-32 символа, начинается с буквы
channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,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_.]{3,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_.]{3,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
}