using FlaUI.Core.AutomationElements; using FlaUI.Core.Input; using WxAgent.Core; namespace WxAgent.Windows; public static partial class WechatChatClient { public static Task ScrollSessionsAsync(WechatScrollDirection direction, int pages = 1, CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, _) => { WechatNavigationRules.ValidateScrollLimit(pages); if (!Enum.IsDefined(direction)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown scroll direction."); var before = ReadSessionViewport(main); var initial = SessionViewportKey(before); var scrolls = 0; for (; scrolls < pages;) { cancellationToken.ThrowIfCancellationRequested(); ScrollNavigationList(main, WechatLocators.SessionList, direction); scrolls++; await Task.Delay(350, cancellationToken).ConfigureAwait(false); var after = ReadSessionViewport(main); if (SessionViewportKey(before) == SessionViewportKey(after)) break; before = after; } var sessions = ReadSessionViewport(main); return new WechatSessionScrollResult(scrolls, initial != SessionViewportKey(sessions), sessions); }, cancellationToken); public static Task> GoToLatestAsync(string session, int maxScrolls = 100, CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, _) => { ArgumentException.ThrowIfNullOrWhiteSpace(session); WechatNavigationRules.ValidateScrollLimit(maxScrolls); await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); await GoToLatestCoreAsync(main, session, maxScrolls, cancellationToken).ConfigureAwait(false); return ReadVisible(main); }, cancellationToken); public static Task LocateMessageAsync(string session, string fingerprint, int maxScrolls = 30, CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, _) => { ArgumentException.ThrowIfNullOrWhiteSpace(session); WechatNavigationRules.ValidateScrollLimit(maxScrolls); WechatNavigationRules.FindMessage([], fingerprint); await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); var previous = ""; var unchanged = 0; for (var page = 0; page <= maxScrolls; page++) { cancellationToken.ThrowIfCancellationRequested(); RequireNavigationSession(main, session); var visible = ReadVisible(main); var match = WechatNavigationRules.FindMessage(visible, fingerprint); if (match is not null) { var element = FindVisibleMessageElement(main, match.Fingerprint); if (element.Patterns.ScrollItem.IsSupported) ExecuteInputStep("locate-message", () => element.Patterns.ScrollItem.Pattern.ScrollIntoView()); element = FindVisibleMessageElement(main, match.Fingerprint); var list = FindByAutomationId(main, WechatLocators.MessageList)!; if (element.Properties.IsOffscreen.ValueOrDefault || !element.BoundingRectangle.IntersectsWith(list.BoundingRectangle)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The matched message could not be brought into the visible viewport."); RequireNavigationSession(main, session); return new WechatMessagePosition(match, page); } var signature = string.Join('|', visible.Select(message => message.Fingerprint)); unchanged = signature == previous ? unchanged + 1 : 0; if (unchanged >= 2) throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The message was not found before the history viewport stopped changing."); previous = signature; if (page == maxScrolls) break; ScrollNavigationList(main, WechatLocators.MessageList, WechatScrollDirection.Up); await Task.Delay(350, cancellationToken).ConfigureAwait(false); } throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The history search reached its scroll limit; absence was not established."); }, cancellationToken); private static async Task GoToLatestCoreAsync(AutomationElement main, string session, int maxScrolls, CancellationToken cancellationToken) { var previous = ""; var unchanged = 0; for (var page = 0; page <= maxScrolls; page++) { cancellationToken.ThrowIfCancellationRequested(); RequireNavigationSession(main, session); var list = FindByAutomationId(main, WechatLocators.MessageList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Message list was not found."); if (list.Patterns.Scroll.IsSupported) { var pattern = list.Patterns.Scroll.Pattern; ExecuteInputStep("latest-messages", () => pattern.SetScrollPercent(-1, 100)); await Task.Delay(350, cancellationToken).ConfigureAwait(false); RequireNavigationSession(main, session); var fresh = FindByAutomationId(main, WechatLocators.MessageList)!; if (fresh.Patterns.Scroll.IsSupported && fresh.Patterns.Scroll.Pattern.VerticalScrollPercent.Value >= 99.9) return; } var signature = string.Join('|', ReadVisible(main).Select(message => message.Fingerprint)); unchanged = signature == previous ? unchanged + 1 : 0; if (unchanged >= 2) return; // Bounded stable-viewport fallback; not a database history completeness claim. previous = signature; if (page == maxScrolls) break; ScrollNavigationList(main, WechatLocators.MessageList, WechatScrollDirection.Down); await Task.Delay(350, cancellationToken).ConfigureAwait(false); } throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The latest-message navigation reached its scroll limit."); } public static Task OpenMergedChatAsync(string session, string fingerprint, CancellationToken cancellationToken = default) => WithMainWindowAsync(async (main, automation) => { await OpenNamedSessionAsync(main, session, cancellationToken).ConfigureAwait(false); var window = await OpenMergedChatWindowCoreAsync(main, automation, session, fingerprint, cancellationToken).ConfigureAwait(false); return window.Properties.NativeWindowHandle.ValueOrDefault.ToInt64(); }, cancellationToken); private static async Task OpenMergedChatWindowCoreAsync(AutomationElement main, FlaUI.UIA3.UIA3Automation automation, string session, string fingerprint, CancellationToken cancellationToken) { RequireNavigationSession(main, session); var message = WechatNavigationRules.FindMessage(ReadVisible(main), fingerprint) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The merged-chat message is not currently loaded; locate it first."); if (message.Type != ChatMessageType.Merge) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The selected message is not a merged-chat record."); var before = automation.GetDesktop().FindAllChildren() .Select(element => element.Properties.NativeWindowHandle.ValueOrDefault).ToHashSet(); var bubble = FindVisibleMessageElement(main, message.Fingerprint); ExecuteInputStep("open-merged-chat", () => bubble.DoubleClick()); var started = System.Diagnostics.Stopwatch.StartNew(); while (started.Elapsed < TimeSpan.FromSeconds(8)) { cancellationToken.ThrowIfCancellationRequested(); var windows = automation.GetDesktop().FindAllChildren() .Where(element => element.Properties.ProcessId.ValueOrDefault == main.Properties.ProcessId.ValueOrDefault && !before.Contains(element.Properties.NativeWindowHandle.ValueOrDefault) && !element.Properties.IsOffscreen.ValueOrDefault && !element.BoundingRectangle.IsEmpty && SafeName(element).Contains("聊天记录", StringComparison.Ordinal)).ToArray(); if (windows.Length > 1) throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Multiple new merged-chat windows appeared."); if (windows.Length == 1) return windows[0]; await Task.Delay(200, cancellationToken).ConfigureAwait(false); } throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No unique new merged-chat window was confirmed."); } private static void RequireNavigationSession(AutomationElement main, string session) { if (!IsListeningSession(main, session, independent: false)) throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed during navigation."); } private static void ScrollNavigationList(AutomationElement main, string automationId, WechatScrollDirection direction) { var list = FindByAutomationId(main, automationId) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The navigation list was not found."); ExecuteInputStep("scroll-navigation-list", () => { if (list.Patterns.Scroll.IsSupported) { var pattern = list.Patterns.Scroll.Pattern; if (!pattern.VerticallyScrollable.Value) return; var step = Math.Max(1, pattern.VerticalViewSize.Value * 0.8); var percent = Math.Clamp(pattern.VerticalScrollPercent.Value + (direction == WechatScrollDirection.Up ? -step : step), 0, 100); pattern.SetScrollPercent(-1, percent); return; } MoveToCenter(list); Mouse.Scroll(direction == WechatScrollDirection.Up ? 5 : -5); }); } private static IReadOnlyList ReadSessionViewport(AutomationElement main) { var list = FindByAutomationId(main, WechatLocators.SessionList) ?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Session list was not found."); var current = SafeName(FindByAutomationId(main, WechatLocators.CurrentChatName)); return WechatSessionParser.Parse(list.FindAllDescendants() .Where(element => !element.Properties.IsOffscreen.ValueOrDefault && element.BoundingRectangle.IntersectsWith(list.BoundingRectangle)) .Select(element => (SafeAutomationId(element), SafeName(element))), current); } private static string SessionViewportKey(IReadOnlyList sessions) => string.Join('|', sessions.Select(session => session.AutomationId)); }