using System.Text.RegularExpressions; using Deal.Modules.Kanban.Application.Models; namespace Deal.Modules.Pipeline.Application.Parse; /// /// Квалификация контактов из разбора/текста сообщения. /// public static class ContactsQualifier { /// /// Максимум записей контактов карточки. /// public const int MaxContacts = 6; internal const int MaxTextCandidates = 4; private const int MaxContactLength = 300; private const int MinHandleLength = 4; private const int MaxHandleLength = 32; internal const int MaxNormalizedPhoneLength = 18; private const string BotSuffix = "bot"; private static readonly IReadOnlySet ReservedProfileNames = new HashSet(StringComparer.Ordinal) { "joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login", }; private static readonly IReadOnlySet SkipSiteHosts = new HashSet(StringComparer.Ordinal) { "teletype.in", "forms.gle", "docs.google.com", "youtube.com", "youtu.be", "clck.ru", }; private static readonly Regex ProfileHandleRe = new(@"\A[A-Za-z0-9_]{4,32}\z", RegexOptions.CultureInvariant); private static readonly Regex ProfileLinkRe = new( @"\Ahttps?://(?:www\.)?t\.me/([A-Za-z0-9_]{4,32})/?\z", RegexOptions.CultureInvariant); private static readonly Regex EmailRe = new( @"\A[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\z", RegexOptions.CultureInvariant); private static readonly Regex PhoneRe = new(@"\A\+?[\d\s\-()]{6,20}\z", RegexOptions.CultureInvariant); private static readonly Regex LinkedinRe = new(@"linkedin\.com/in/", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static readonly Regex WhatsappRe = new(@"wa\.me|api\.whatsapp\.com", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static readonly Regex SiteHostRe = new(@"https?://(?:www\.)?", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant); private static readonly Regex AtNameRe = new(@"@[A-Za-z0-9_]{3,}", RegexOptions.CultureInvariant); private static readonly Regex EmailInTextRe = new( @"[A-Za-z0-9._%+\-]+@[A-Za-z0-9\-]+(?:\.[A-Za-z0-9\-]+)+", RegexOptions.CultureInvariant); private static readonly Regex PhoneInTextRe = new( @"(?:\+7|8|7)[\s\-()]*\d{3}[\s\-()]*\d{3}[\s\-]*\d{2}[\s\-]*\d{2}", RegexOptions.CultureInvariant); private static readonly IReadOnlyDictionary PrimaryOrder = new Dictionary(StringComparer.Ordinal) { ["tg"] = 0, ["phone"] = 1, ["whatsapp"] = 2, ["email"] = 3, ["linkedin"] = 4, ["site"] = 5, }; private const int UnknownTypePriority = 9; /// /// Классифицирует один сырой контакт → {type, value} или null. /// /// Сырое значение контакта («@user», ссылка на профиль, телефон, email, ссылка). /// Квалифицированный контакт либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт. public static CardContactDto? Qualify(string? raw) { string s = (raw ?? string.Empty).Trim(); if (s.Length == 0 || s.Length > MaxContactLength) { return null; } if (s.StartsWith('@')) { string name = s[1..].Trim(); if (ProfileHandleRe.IsMatch(name) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase)) { return new CardContactDto("tg", "@" + name); } return null; } Match link = ProfileLinkRe.Match(s); if (link.Success) { string name = link.Groups[1].Value; if (!ReservedProfileNames.Contains(name.ToLowerInvariant()) && !name.EndsWith(BotSuffix, StringComparison.OrdinalIgnoreCase)) { return new CardContactDto("tg", "@" + name); } return null; } if (EmailRe.IsMatch(s)) { return new CardContactDto("email", s.ToLowerInvariant()); } string digits = new string(s.Where(char.IsDigit).ToArray()); if (PhoneRe.IsMatch(s) && digits.Length is >= 10 and <= 15) { return new CardContactDto("phone", (s.StartsWith('+') ? "+" : string.Empty) + digits); } if (LinkedinRe.IsMatch(s)) { return new CardContactDto("linkedin", s); } if (WhatsappRe.IsMatch(s)) { return new CardContactDto("whatsapp", s); } if (s.StartsWith("http", StringComparison.OrdinalIgnoreCase)) { string host = SiteHostRe.Replace(s.ToLowerInvariant(), string.Empty) .Split('/')[0] .Split('?')[0] .Split(':')[0]; if (SkipSiteHosts.Contains(host) || host.EndsWith(".teletype.in", StringComparison.Ordinal)) { return null; } return new CardContactDto("site", s); } return null; } /// /// Собирает квалифицированные контакты из разбора/текста. /// /// Сырые контакты разбора: строка со значениями через ;/|/перенос. /// Исходный текст сообщения — кандидаты, если в разборе контактов нет. /// До записей {type, value} без дублей (casefold-значение). public static IReadOnlyList Build(string? contacts, string? text) { var candidates = new List(); if (contacts is not null) { candidates.AddRange(Regex.Split(contacts, @"[;|\n]+", RegexOptions.CultureInvariant)); if (candidates.Count == 0) { candidates.Add(string.Empty); } } return BuildFromCandidates(candidates, text); } /// /// Собирает квалифицированные контакты из списка значений разбора. /// /// Список сырых значений контактов (пустой/null — извлечение из текста). /// Исходный текст сообщения — кандидаты, если список пуст. /// До записей {type, value} без дублей. public static IReadOnlyList Build(IEnumerable? contactValues, string? text) { var candidates = new List(); if (contactValues is not null) { foreach (string value in contactValues) { if (string.IsNullOrEmpty(value)) { continue; } candidates.AddRange(Regex.Split(value, @"[;|\n]+", RegexOptions.CultureInvariant)); } } return BuildFromCandidates(candidates, text); } /// /// Основной контакт карточки — значение с наименьшим приоритетом. /// /// Квалифицированные контакты (см. ). /// Значение основного контакта или пустая строка, если контактов нет. public static string Primary(IReadOnlyList contacts) { if (contacts.Count == 0) { return string.Empty; } CardContactDto best = contacts[0]; int bestOrder = OrderOf(best.Type); foreach (CardContactDto contact in contacts) { int order = OrderOf(contact.Type); if (order < bestOrder) { best = contact; bestOrder = order; } } return best.Value; } /// /// Извлекает кандидатов контактов из текста /// /// Текст сообщения или значение метки «Контакты: …». /// До сырых кандидатов в порядке появления (телефон — нормализован). public static IReadOnlyList ExtractFromText(string? body) { string text = body ?? string.Empty; var result = new List(); AddUnique(result, AtNameRe.Matches(text).Select(m => m.Value)); AddUnique(result, EmailInTextRe.Matches(text).Select(m => m.Value)); AddUnique(result, PhoneInTextRe.Matches(text).Select(m => NormalizePhone(m.Value))); return result.Count > MaxTextCandidates ? result.Take(MaxTextCandidates).ToList() : result; } internal static string NormalizePhone(string phone) { string normalized = phone.Replace(" ", string.Empty) .Replace("\u00a0", string.Empty) .Replace("-", string.Empty) .Replace("(", string.Empty) .Replace(")", string.Empty); return normalized.Length > MaxNormalizedPhoneLength ? normalized[..MaxNormalizedPhoneLength] : normalized; } private static int OrderOf(string type) { return PrimaryOrder.TryGetValue(type, out int order) ? order : UnknownTypePriority; } private static IReadOnlyList BuildFromCandidates(List candidates, string? text) { if (candidates.Count == 0) { candidates.AddRange(ExtractFromText(text)); } var result = new List(); var seen = new HashSet(StringComparer.Ordinal); foreach (string candidate in candidates) { CardContactDto? qualified = Qualify(candidate); if (qualified is null) { continue; } string key = qualified.Value.ToLowerInvariant(); if (!seen.Add(key)) { continue; } result.Add(qualified); if (result.Count >= MaxContacts) { break; } } return result; } private static void AddUnique(List result, IEnumerable values) { foreach (string value in values) { if (!result.Contains(value, StringComparer.Ordinal)) { result.Add(value); } } } }