Карточка, очередь и отсев работают с SourceItem (SourceRef + SourceContent); Telegram-поля убраны из домена, персистентности, конвейера и wire, остались только в адаптере приёма. Tenant-миграции пересозданы с нуля. Фронт переведён на generic source/content с просмотрщиком вложений.
302 lines
11 KiB
C#
302 lines
11 KiB
C#
using System.Text.RegularExpressions;
|
|
using Deal.Modules.Kanban.Application.Models;
|
|
|
|
namespace Deal.Modules.Pipeline.Application.Parse;
|
|
|
|
/// <summary>
|
|
/// Квалификация контактов из разбора/текста сообщения.
|
|
/// </summary>
|
|
public static class ContactsQualifier
|
|
{
|
|
/// <summary>
|
|
/// Максимум записей контактов карточки.
|
|
/// </summary>
|
|
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<string> ReservedProfileNames = new HashSet<string>(StringComparer.Ordinal)
|
|
{
|
|
"joinchat", "share", "s", "c", "addstickers", "addtheme", "proxy", "bg", "login",
|
|
};
|
|
|
|
private static readonly IReadOnlySet<string> SkipSiteHosts = new HashSet<string>(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<string, int> PrimaryOrder = new Dictionary<string, int>(StringComparer.Ordinal)
|
|
{
|
|
["tg"] = 0,
|
|
["phone"] = 1,
|
|
["whatsapp"] = 2,
|
|
["email"] = 3,
|
|
["linkedin"] = 4,
|
|
["site"] = 5,
|
|
};
|
|
|
|
private const int UnknownTypePriority = 9;
|
|
|
|
/// <summary>
|
|
/// Классифицирует один сырой контакт → {type, value} или null.
|
|
/// </summary>
|
|
/// <param name="raw">Сырое значение контакта («@user», ссылка на профиль, телефон, email, ссылка).</param>
|
|
/// <returns>Квалифицированный контакт <see cref="CardContactDto"/> либо null — пусто/мусор/бот/сервисная ссылка/ «постовый» сайт.</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Собирает квалифицированные контакты из разбора/текста.
|
|
/// </summary>
|
|
/// <param name="contacts">Сырые контакты разбора: строка со значениями через <c>;</c>/<c>|</c>/перенос.</param>
|
|
/// <param name="text">Исходный текст сообщения — кандидаты, если в разборе контактов нет.</param>
|
|
/// <returns>До <see cref="MaxContacts"/> записей {type, value} без дублей (casefold-значение).</returns>
|
|
public static IReadOnlyList<CardContactDto> Build(string? contacts, string? text)
|
|
{
|
|
var candidates = new List<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Собирает квалифицированные контакты из списка значений разбора.
|
|
/// </summary>
|
|
/// <param name="contactValues">Список сырых значений контактов (пустой/null — извлечение из текста).</param>
|
|
/// <param name="text">Исходный текст сообщения — кандидаты, если список пуст.</param>
|
|
/// <returns>До <see cref="MaxContacts"/> записей {type, value} без дублей.</returns>
|
|
public static IReadOnlyList<CardContactDto> Build(IEnumerable<string>? contactValues, string? text)
|
|
{
|
|
var candidates = new List<string>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Основной контакт карточки — значение с наименьшим приоритетом.
|
|
/// </summary>
|
|
/// <param name="contacts">Квалифицированные контакты (см. <see cref="Build"/>).</param>
|
|
/// <returns>Значение основного контакта или пустая строка, если контактов нет.</returns>
|
|
public static string Primary(IReadOnlyList<CardContactDto> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Извлекает кандидатов контактов из текста
|
|
/// </summary>
|
|
/// <param name="body">Текст сообщения или значение метки «Контакты: …».</param>
|
|
/// <returns>До <see cref="MaxTextCandidates"/> сырых кандидатов в порядке появления (телефон — нормализован).</returns>
|
|
public static IReadOnlyList<string> ExtractFromText(string? body)
|
|
{
|
|
string text = body ?? string.Empty;
|
|
var result = new List<string>();
|
|
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<CardContactDto> BuildFromCandidates(List<string> candidates, string? text)
|
|
{
|
|
if (candidates.Count == 0)
|
|
{
|
|
candidates.AddRange(ExtractFromText(text));
|
|
}
|
|
|
|
var result = new List<CardContactDto>();
|
|
var seen = new HashSet<string>(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<string> result, IEnumerable<string> values)
|
|
{
|
|
foreach (string value in values)
|
|
{
|
|
if (!result.Contains(value, StringComparer.Ordinal))
|
|
{
|
|
result.Add(value);
|
|
}
|
|
}
|
|
}
|
|
}
|