using System.Text.RegularExpressions; namespace WxAgent.Core; public sealed record WechatSearchExpansion(string Category, int RowIndex, int ExpectedCount); public sealed record WechatSearchPage( IReadOnlyList Results, IReadOnlyList Expansions); public static class WechatSearchRules { private static readonly Regex More = new(@"^查看全部\s*[((](\d+)[))]$", RegexOptions.CultureInvariant); public static bool IsLocalCategory(string? name) => name is "联系人" or "群聊" or "功能" or "最常使用"; public static bool IsExpansionLabel(string name) => More.IsMatch(name.Trim()); public static WechatSearchPage Parse( IReadOnlyList<(string AutomationId, string AccessibleName)> rows, string query, string? expandedCategory = null) { if (expandedCategory is not null && expandedCategory is not ("联系人" or "群聊")) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Only local contacts and group chats may be expanded."); var results = new List(); var expansions = new List(); var category = expandedCategory; for (var index = 0; index < rows.Count; index++) { var row = rows[index]; var name = row.AccessibleName.Trim(); if (!row.AutomationId.StartsWith("search_item_", StringComparison.Ordinal)) { if (IsLocalCategory(name)) { category = name; continue; } var more = More.Match(name); if (more.Success && category is "联系人" or "群聊") { if (!int.TryParse(more.Groups[1].Value, out var count) || count is < 1 or > 10000) throw new WxAgentException(WxAgentErrorCode.UiStructureChanged, "Invalid local search result count."); expansions.Add(new WechatSearchExpansion(category, index, count)); continue; } // An unrecognized section must not inherit local trust from an expanded category. category = null; continue; } if (category is null || row.AutomationId.StartsWith("search_item_web_", StringComparison.Ordinal) || row.AutomationId.StartsWith("search_item_online_", StringComparison.Ordinal)) continue; var parsed = WechatSessionParser.ParseSearchResults([row], query).SingleOrDefault(); if (parsed is null) continue; var lines = row.AccessibleName.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var idName = row.AutomationId["search_item_".Length..]; var displayName = lines.Contains(idName, StringComparer.Ordinal) ? idName : parsed.Name == category ? lines.LastOrDefault(line => line != category) ?? string.Empty : parsed.Name; if (displayName.Length == 0 || (category == "功能" && displayName != WechatLocators.FileTransferAssistant)) continue; // The promoted local row uses the same name-based ID as contacts/groups. // Do not admit arbitrary function, online, or future result families under this mixed header. if (category == "最常使用" && row.AutomationId != "search_item_" + displayName && row.AutomationId != WechatLocators.FileTransferAssistantSearchResult) continue; // Preserve duplicates: two same-named local conversations must be reported as ambiguous, not silently merged. results.Add(parsed with { Name = displayName, Category = category, IsExactMatch = string.Equals(displayName, query, StringComparison.Ordinal) }); } return new WechatSearchPage(results, expansions); } public static WechatSessionSearchResult RequireExact(IReadOnlyList results, string name) { var matches = results.Where(result => IsLocalCategory(result.Category) && string.Equals(result.Name, name, StringComparison.Ordinal)).ToArray(); return matches.Length switch { 1 => matches[0], 0 => throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The exact local conversation was not found."), _ => throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple local conversations have the same name; refusing an ambiguous selection.") }; } }