refactor: split node agent and Go control plane
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
using FlaUI.Core.AutomationElements;
|
||||
using FlaUI.Core.Input;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public static partial class WechatChatClient
|
||||
{
|
||||
public static Task<WechatSessionScrollResult> 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<IReadOnlyList<ChatMessageSnapshot>> 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<WechatMessagePosition> 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<long> 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<AutomationElement> 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<WechatSessionSnapshot> 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<WechatSessionSnapshot> sessions) =>
|
||||
string.Join('|', sessions.Select(session => session.AutomationId));
|
||||
}
|
||||
Reference in New Issue
Block a user