904 lines
42 KiB
C#
904 lines
42 KiB
C#
using System.Collections.Specialized;
|
|
using System.Runtime.CompilerServices;
|
|
using System.Threading.Channels;
|
|
using System.Windows.Forms;
|
|
using FlaUI.Core.AutomationElements;
|
|
using FlaUI.Core.Definitions;
|
|
using FlaUI.Core.Input;
|
|
using FlaUI.Core.WindowsAPI;
|
|
using FlaUI.UIA3;
|
|
using WxAgent.Core;
|
|
|
|
namespace WxAgent.Windows;
|
|
|
|
public static partial class WechatChatClient
|
|
{
|
|
public static IDisposable UseWindowTarget(int processId, long windowHandle) => WechatDoctor.UseWindow(processId, windowHandle);
|
|
|
|
private static readonly InterprocessCommandGate CommandQueue = CreateCommandGate();
|
|
|
|
private static InterprocessCommandGate CreateCommandGate()
|
|
{
|
|
using var process = System.Diagnostics.Process.GetCurrentProcess();
|
|
return new InterprocessCommandGate(Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
"WxAgent", $"ui-session-{process.SessionId}.lock"));
|
|
}
|
|
|
|
public static Task<IReadOnlyList<WechatSessionSnapshot>> ListVisibleSessionsAsync(CancellationToken cancellationToken) =>
|
|
WithMainWindowAsync((window, _) =>
|
|
{
|
|
var list = FindByAutomationId(window, WechatLocators.SessionList)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.SessionList} was not found.");
|
|
var current = SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName));
|
|
var sessions = WechatSessionParser.Parse(
|
|
list.FindAllDescendants()
|
|
.Where(element => SafeControlType(element) == ControlType.ListItem)
|
|
.Select(element => (SafeAutomationId(element), SafeName(element))),
|
|
current);
|
|
return Task.FromResult<IReadOnlyList<WechatSessionSnapshot>>(sessions);
|
|
}, cancellationToken);
|
|
|
|
public static async Task<IReadOnlyList<WechatSessionSearchResult>> SearchSessionsAsync(
|
|
string query,
|
|
bool exactOnly,
|
|
CancellationToken cancellationToken,
|
|
string? uiOutput = null)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
|
await CommandQueue.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
var window = AttachWindow(automation, cancellationToken);
|
|
var search = window.FindAllDescendants().FirstOrDefault(element =>
|
|
SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found.");
|
|
var box = search.AsTextBox();
|
|
var previous = box.Text;
|
|
try
|
|
{
|
|
var results = await SearchLocalSessionsCoreAsync(window, query, cancellationToken).ConfigureAwait(false);
|
|
if (uiOutput is not null)
|
|
await WechatUiInspector.CaptureAsync(uiOutput, cancellationToken).ConfigureAwait(false);
|
|
return exactOnly ? results.Where(result => result.IsExactMatch).ToArray() : results;
|
|
}
|
|
finally
|
|
{
|
|
box.Text = previous;
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
public static Task<WechatSessionSnapshot?> GetCurrentSessionAsync(CancellationToken cancellationToken) =>
|
|
WithMainWindowAsync((window, _) =>
|
|
{
|
|
var element = FindByAutomationId(window, WechatLocators.CurrentChatName);
|
|
var name = SafeName(element);
|
|
return Task.FromResult(string.IsNullOrWhiteSpace(name)
|
|
? null
|
|
: new WechatSessionSnapshot(name, SafeAutomationId(element!), true));
|
|
}, cancellationToken);
|
|
|
|
public static async Task<WechatSessionSnapshot> OpenSessionAsync(string name, CancellationToken cancellationToken, string? query = null)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
|
if (query is not null) ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
|
await CommandQueue.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
var window = AttachWindow(automation, cancellationToken);
|
|
if (query is not null)
|
|
{
|
|
var selected = await OpenLocalSearchSessionAsync(window, name, cancellationToken, query).ConfigureAwait(false);
|
|
return new WechatSessionSnapshot(name, selected.AutomationId, true);
|
|
}
|
|
await OpenNamedSessionAsync(window, name, cancellationToken).ConfigureAwait(false);
|
|
var current = FindByAutomationId(window, WechatLocators.CurrentChatName);
|
|
var currentAutomationId = current is null ? null : SafeAutomationId(current);
|
|
return new WechatSessionSnapshot(name, currentAutomationId ?? "session_item_" + name, true);
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken,
|
|
string session = WechatLocators.FileTransferAssistant)
|
|
{
|
|
text = WechatTextInput.Prepare(text);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(session);
|
|
|
|
await CommandQueue.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
var window = AttachWindow(automation, cancellationToken);
|
|
await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false);
|
|
await Task.Delay(500, cancellationToken);
|
|
if (!IsListeningSession(window, session, independent: false))
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before input.");
|
|
var baseline = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal);
|
|
|
|
var input = FindByAutomationId(window, WechatLocators.ChatInput)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found.");
|
|
var inputBox = input.AsTextBox();
|
|
if (!string.IsNullOrEmpty(inputBox.Text))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten.");
|
|
ExecuteInputStep("set-input-value", () => inputBox.Text = text);
|
|
await Task.Delay(150, cancellationToken);
|
|
if (!IsListeningSession(window, session, independent: false) || !string.Equals(inputBox.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted.");
|
|
}
|
|
|
|
// Setting input text can recreate the send control; acquire it only after the input mutation.
|
|
var send = window.FindAllDescendants().FirstOrDefault(element =>
|
|
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after input.");
|
|
ExecuteInputStep("click-send", () => ClickCenter(send));
|
|
|
|
var confirmed = await WaitForMessageAsync(text, session, baseline, TimeSpan.FromSeconds(20), cancellationToken);
|
|
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The text was entered but no matching visible message confirmed the send result.");
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<ChatMessageSnapshot> ReplyToLatestAsync(string text, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant)
|
|
{
|
|
text = WechatTextInput.Prepare(text);
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(session);
|
|
|
|
await CommandQueue.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
var window = AttachWindow(automation, cancellationToken);
|
|
await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false);
|
|
await Task.Delay(500, cancellationToken);
|
|
if (!IsListeningSession(window, session, independent: false))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before quoting.");
|
|
}
|
|
var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal);
|
|
var list = FindByAutomationId(window, WechatLocators.MessageList)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
|
|
var draft = FindByAutomationId(window, WechatLocators.ChatInput)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Chat input was not found.");
|
|
if (!string.IsNullOrEmpty(draft.AsTextBox().Text))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The conversation contains an existing draft; it was not overwritten.");
|
|
var latest = list.FindAllDescendants()
|
|
.LastOrDefault(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "No visible message is available to quote.");
|
|
RightClickMessageBubble(latest);
|
|
|
|
AutomationElement? quote = null;
|
|
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(30);
|
|
while (DateTimeOffset.UtcNow < deadline && quote is null)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
quote = automation.GetDesktop().FindAllDescendants().FirstOrDefault(element =>
|
|
SessionNameMatches(SafeName(element), "引用") && element.BoundingRectangle.Width > 0);
|
|
if (quote is null) await Task.Delay(100, cancellationToken);
|
|
}
|
|
if (quote is null)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The quote context-menu item was not found.");
|
|
}
|
|
|
|
ClickCenter(quote);
|
|
await Task.Delay(300, cancellationToken);
|
|
window = WechatDoctor.FindWechatWindow(automation) ?? window;
|
|
var input = FindByAutomationId(window, WechatLocators.ChatInput)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found.");
|
|
if (!IsListeningSession(window, session, independent: false))
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before reply input.");
|
|
if (!string.IsNullOrEmpty(input.AsTextBox().Text))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "The reply input is not empty; it was not overwritten.");
|
|
ExecuteInputStep("set-reply-value", () => input.AsTextBox().Text = text);
|
|
if (!IsListeningSession(window, session, independent: false) || !string.Equals(input.AsTextBox().Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The reply input did not contain the exact requested text; send was aborted.");
|
|
}
|
|
|
|
var send = window.FindAllDescendants().FirstOrDefault(element =>
|
|
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found.");
|
|
ClickCenter(send);
|
|
var confirmed = await WaitForNewMessageAsync(before, session, ChatMessageType.Quote, text, TimeSpan.FromSeconds(30), cancellationToken);
|
|
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "No new visible quoted reply confirmed the send result.");
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
public static Task<ChatMessageSnapshot> SendFileAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
|
|
SendAttachmentAsync(path, asImage: false, session, cancellationToken);
|
|
|
|
public static Task<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken, string session = WechatLocators.FileTransferAssistant) =>
|
|
SendAttachmentAsync(path, asImage: true, session, cancellationToken);
|
|
|
|
private static async Task<ChatMessageSnapshot> SendAttachmentAsync(string path, bool asImage, string session, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(session);
|
|
var fullPath = Path.GetFullPath(path);
|
|
if (!File.Exists(fullPath))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "The attachment file does not exist.");
|
|
}
|
|
|
|
await CommandQueue.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
var window = AttachWindow(automation, cancellationToken);
|
|
await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false);
|
|
await Task.Delay(500, cancellationToken);
|
|
if (!IsListeningSession(window, session, independent: false))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested conversation changed before attachment input.");
|
|
}
|
|
var before = ReadVisible(window).Select(message => message.Fingerprint).ToHashSet(StringComparer.Ordinal);
|
|
var input = FindByAutomationId(window, WechatLocators.ChatInput)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found.");
|
|
var inputText = input.AsTextBox().Text;
|
|
if (!string.IsNullOrEmpty(inputText))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"The chat input contains an unsent draft ({inputText.Length} characters); attachment send was aborted.");
|
|
}
|
|
|
|
ClickCenter(input);
|
|
await Task.Delay(150, cancellationToken);
|
|
using (new ClipboardLease(fullPath, asImage))
|
|
{
|
|
if (!IsListeningSession(window, session, independent: false))
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment paste.");
|
|
ExecuteInputStep("paste-attachment", () => Keyboard.TypeSimultaneously([VirtualKeyShort.CONTROL, VirtualKeyShort.KEY_V]));
|
|
await Task.Delay(500, cancellationToken);
|
|
}
|
|
|
|
var refreshedWindow = WechatDoctor.FindWechatWindow(automation)
|
|
?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window disappeared after attachment paste.");
|
|
if (!IsListeningSession(refreshedWindow, session, independent: false))
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed before attachment send.");
|
|
var send = refreshedWindow.FindAllDescendants().FirstOrDefault(element =>
|
|
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found after attachment paste.");
|
|
ClickCenter(send);
|
|
var confirmed = await WaitForNewMessageAsync(before, session,
|
|
asImage ? ChatMessageType.Image : ChatMessageType.File, asImage ? null : Path.GetFileName(fullPath),
|
|
TimeSpan.FromSeconds(30), cancellationToken);
|
|
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no matching new visible message confirmed the send result.");
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<IReadOnlyList<ChatMessageSnapshot>> ReadHistoryAsync(
|
|
int maxMessages,
|
|
int maxScrolls,
|
|
CancellationToken cancellationToken,
|
|
string session = WechatLocators.FileTransferAssistant)
|
|
{
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(session);
|
|
if (maxMessages is < 1 or > 1000 || maxScrolls is < 0 or > 100)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "History limits are maxMessages 1-1000 and maxScrolls 0-100.");
|
|
}
|
|
|
|
await CommandQueue.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
var window = AttachWindow(automation, cancellationToken);
|
|
await OpenNamedSessionAsync(window, session, cancellationToken).ConfigureAwait(false);
|
|
var messages = new List<ChatMessageSnapshot>();
|
|
var deduper = new BoundedMessageDeduper(Math.Max(2048, maxMessages * 4));
|
|
var unchanged = 0;
|
|
var scrolled = 0;
|
|
for (var page = 0; page <= maxScrolls; page++)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
RequireNavigationSession(window, session);
|
|
var visible = ReadVisible(window);
|
|
var older = visible.Where(message => deduper.TryAdd(message.Fingerprint)).ToArray();
|
|
if (older.Length == 0) unchanged++; else unchanged = 0;
|
|
if (page == 0) messages.AddRange(older); else messages.InsertRange(0, older);
|
|
if (messages.Count >= maxMessages || unchanged >= 2 || page == maxScrolls) break;
|
|
|
|
ScrollNavigationList(window, WechatLocators.MessageList, WechatScrollDirection.Up);
|
|
scrolled++;
|
|
await Task.Delay(350, cancellationToken);
|
|
}
|
|
|
|
if (scrolled > 0)
|
|
{
|
|
await GoToLatestCoreAsync(window, session, 100, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
return messages.Count <= maxMessages ? messages : messages.TakeLast(maxMessages).ToArray();
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
public static async Task<IReadOnlyList<ChatMessageSnapshot>> ReadVisibleAsync(CancellationToken cancellationToken)
|
|
{
|
|
await CommandQueue.WaitAsync(cancellationToken);
|
|
try
|
|
{
|
|
using var automation = new UIA3Automation();
|
|
var window = AttachWindow(automation, cancellationToken);
|
|
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
|
return ReadVisible(window);
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
|
|
public static async IAsyncEnumerable<ChatMessageSnapshot> ListenAsync(
|
|
TimeSpan duration,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await foreach (var item in ListenEventsAsync(duration, checkpointPath: null, cancellationToken))
|
|
{
|
|
if (item.Kind == MessageEventKind.MessageReceived && item.Message is { } message)
|
|
yield return message;
|
|
}
|
|
}
|
|
|
|
public static async IAsyncEnumerable<MessageEvent> ListenEventsAsync(
|
|
TimeSpan duration,
|
|
string? checkpointPath,
|
|
[EnumeratorCancellation] CancellationToken cancellationToken,
|
|
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>>? callbacks = null,
|
|
Action? snapshotObserved = null,
|
|
string session = WechatLocators.FileTransferAssistant,
|
|
bool independentWindow = false)
|
|
{
|
|
if (duration <= TimeSpan.Zero)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive.");
|
|
}
|
|
|
|
WechatOperationPolicy.ValidateNames([session], nameof(session));
|
|
WechatDesktop.EnsureInputAvailable();
|
|
var checkpoint = await ListenerCheckpointStore.LoadAsync(checkpointPath, cancellationToken);
|
|
if (checkpoint is not null && !string.Equals(checkpoint.Session, session, StringComparison.Ordinal))
|
|
{
|
|
checkpoint = null;
|
|
}
|
|
|
|
var state = new MessageEventState(checkpoint: checkpoint);
|
|
var recoveredScan = checkpoint is not null;
|
|
var signals = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
|
using var automation = new UIA3Automation();
|
|
AutomationElement? window = null;
|
|
IDisposable? subscription = null;
|
|
var connected = false;
|
|
nint subscribedWindow = 0;
|
|
var bindingAttempted = false;
|
|
var observedSnapshot = false;
|
|
Exception? lastReadError = null;
|
|
var announceReconnect = recoveredScan;
|
|
var elapsed = System.Diagnostics.Stopwatch.StartNew();
|
|
using var readDeadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
readDeadline.CancelAfter(duration);
|
|
|
|
try
|
|
{
|
|
while (elapsed.Elapsed < duration)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
IReadOnlyList<ChatMessageSnapshot>? visible = null;
|
|
try
|
|
{
|
|
await CommandQueue.WaitAsync(readDeadline.Token);
|
|
try
|
|
{
|
|
bindingAttempted = true;
|
|
window = await BindListeningWindowAsync(automation, session, independentWindow, readDeadline.Token).ConfigureAwait(false);
|
|
var handle = window.Properties.NativeWindowHandle.Value;
|
|
if (subscribedWindow != 0 && handle != subscribedWindow)
|
|
{
|
|
connected = false;
|
|
announceReconnect = true;
|
|
}
|
|
if (!connected)
|
|
{
|
|
subscription?.Dispose();
|
|
var list = FindByAutomationId(window, WechatLocators.MessageList)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Listener message list was not found.");
|
|
subscription = list.RegisterStructureChangedEvent(
|
|
TreeScope.Subtree,
|
|
(_, _, _) => signals.Writer.TryWrite(true));
|
|
subscribedWindow = handle;
|
|
connected = true;
|
|
}
|
|
if (!IsListeningSession(window, session, independentWindow))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed before snapshot.");
|
|
var snapshot = ReadVisible(window);
|
|
if (!IsListeningSession(window, session, independentWindow))
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener session changed during snapshot.");
|
|
visible = snapshot;
|
|
observedSnapshot = true;
|
|
snapshotObserved?.Invoke();
|
|
}
|
|
finally
|
|
{
|
|
CommandQueue.Release();
|
|
}
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && readDeadline.IsCancellationRequested)
|
|
{
|
|
break;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
lastReadError = exception;
|
|
visible = null;
|
|
connected = false;
|
|
subscription?.Dispose();
|
|
subscription = null;
|
|
}
|
|
|
|
if (connected && announceReconnect)
|
|
{
|
|
announceReconnect = false;
|
|
var reconnectEvent = new MessageEvent(
|
|
"reconnected-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
MessageEventKind.Reconnected,
|
|
session,
|
|
null,
|
|
DateTimeOffset.UtcNow,
|
|
Recovered: true);
|
|
if (callbacks is { Count: > 0 })
|
|
{
|
|
await MessageCallbackDispatcher.DispatchAsync(callbacks, reconnectEvent, cancellationToken);
|
|
}
|
|
yield return reconnectEvent;
|
|
}
|
|
|
|
var changed = false;
|
|
if (visible is not null)
|
|
{
|
|
if (checkpoint is null && !recoveredScan)
|
|
{
|
|
foreach (var message in visible)
|
|
{
|
|
state.TryCreateMessage(session, message, DateTimeOffset.UtcNow, recovered: false);
|
|
}
|
|
changed = true;
|
|
recoveredScan = false;
|
|
}
|
|
else
|
|
{
|
|
foreach (var message in visible)
|
|
{
|
|
var messageEvent = state.TryCreateMessage(session, message, DateTimeOffset.UtcNow, recoveredScan);
|
|
if (messageEvent is not null)
|
|
{
|
|
changed = true;
|
|
if (callbacks is { Count: > 0 })
|
|
{
|
|
await MessageCallbackDispatcher.DispatchAsync(callbacks, messageEvent, cancellationToken);
|
|
}
|
|
yield return messageEvent;
|
|
}
|
|
}
|
|
recoveredScan = false;
|
|
}
|
|
}
|
|
|
|
if (changed)
|
|
{
|
|
await ListenerCheckpointStore.SaveAsync(
|
|
state.CreateCheckpoint(session, DateTimeOffset.UtcNow),
|
|
checkpointPath,
|
|
cancellationToken);
|
|
checkpoint ??= state.CreateCheckpoint(session, DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
if (!connected)
|
|
{
|
|
announceReconnect = true;
|
|
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
|
|
continue;
|
|
}
|
|
|
|
var remaining = duration - elapsed.Elapsed;
|
|
if (remaining <= TimeSpan.Zero)
|
|
{
|
|
break;
|
|
}
|
|
|
|
using var pollCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
pollCancellation.CancelAfter(remaining < TimeSpan.FromSeconds(2) ? remaining : TimeSpan.FromSeconds(2));
|
|
try
|
|
{
|
|
await signals.Reader.WaitToReadAsync(pollCancellation.Token);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
|
{
|
|
}
|
|
while (signals.Reader.TryRead(out _))
|
|
{
|
|
}
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
subscription?.Dispose();
|
|
}
|
|
if (bindingAttempted && !observedSnapshot)
|
|
throw new WxAgentException(lastReadError is WxAgentException error ? error.Code : WxAgentErrorCode.ControlNotFound,
|
|
$"Listener never obtained a valid snapshot (stage: {lastReadError?.TargetSite?.Name ?? "initial binding"}).", lastReadError);
|
|
}
|
|
|
|
private static AutomationElement AttachWindow(UIA3Automation automation, CancellationToken cancellationToken)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
WechatDesktop.EnsureInputAvailable();
|
|
var window = WechatDoctor.FindWechatWindow(automation);
|
|
if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (WechatDoctor.TargetProcessId is null && !WechatTray.TryActivateUia(automation)) WechatTray.Inspect(true, cancellationToken);
|
|
for (var attempt = 0; attempt < 50; attempt++)
|
|
{
|
|
if (cancellationToken.WaitHandle.WaitOne(100)) cancellationToken.ThrowIfCancellationRequested();
|
|
window = WechatDoctor.FindWechatWindow(automation);
|
|
if (window is not null && FindByAutomationId(window, WechatLocators.MainView) is not null) break;
|
|
}
|
|
}
|
|
if (window is null || FindByAutomationId(window, WechatLocators.MainView) is null)
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat tray activation did not restore an accessible main view; no new process was started.");
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
window.Focus();
|
|
return window;
|
|
}
|
|
|
|
private static async Task OpenFileTransferAssistantCoreAsync(AutomationElement window, CancellationToken cancellationToken)
|
|
{
|
|
if (IsFileTransferAssistantOpen(window))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var sessionItem = FindByAutomationId(window, "session_item_" + WechatLocators.FileTransferAssistant);
|
|
if (sessionItem is not null)
|
|
{
|
|
ClickCenter(sessionItem);
|
|
if (await WaitForFileTransferAssistantAsync(window, TimeSpan.FromSeconds(5), cancellationToken))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
var search = window.FindAllDescendants().FirstOrDefault(element =>
|
|
SafeControlType(element) == ControlType.Edit && SafeName(element) == WechatLocators.Search)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "WeChat search box was not found.");
|
|
var searchBox = search.AsTextBox();
|
|
ExecuteInputStep("set-search-value", () => searchBox.Text = WechatLocators.FileTransferAssistant);
|
|
|
|
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
|
|
AutomationElement? searchResult = null;
|
|
while (DateTimeOffset.UtcNow < deadline && searchResult is null)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
searchResult = FindByAutomationId(window, WechatLocators.FileTransferAssistantSearchResult);
|
|
if (searchResult is null)
|
|
{
|
|
await Task.Delay(150, cancellationToken);
|
|
}
|
|
}
|
|
|
|
if (searchResult is null)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "File Transfer Assistant search result was not found.");
|
|
}
|
|
|
|
ClickCenter(searchResult);
|
|
if (!await WaitForFileTransferAssistantAsync(window, TimeSpan.FromSeconds(10), cancellationToken))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "File Transfer Assistant could not be activated from search results.");
|
|
}
|
|
}
|
|
|
|
private static bool IsFileTransferAssistantOpen(AutomationElement window) =>
|
|
SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == WechatLocators.FileTransferAssistant;
|
|
|
|
private static Task<bool> WaitForFileTransferAssistantAsync(
|
|
AutomationElement window,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken) =>
|
|
WaitForSessionAsync(window, WechatLocators.FileTransferAssistant, timeout, cancellationToken);
|
|
|
|
private static async Task<bool> WaitForSessionAsync(
|
|
AutomationElement window,
|
|
string name,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow + timeout;
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
await Task.Delay(150, cancellationToken);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static async Task<ChatMessageSnapshot?> WaitForNewMessageAsync(
|
|
IReadOnlySet<string> previous,
|
|
string session,
|
|
ChatMessageType type,
|
|
string? expectedText,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken,
|
|
string? mentionMember = null)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow + timeout;
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
using var automation = new UIA3Automation();
|
|
var window = WechatDoctor.FindWechatWindow(automation);
|
|
if (window is not null && !IsListeningSession(window, session, independent: false))
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted.");
|
|
var message = window is null ? null : ReadVisible(window).LastOrDefault(item =>
|
|
mentionMember is null
|
|
? WechatSendConfirmation.Matches(item, previous, type, expectedText)
|
|
: WechatSendConfirmation.MatchesMention(item, previous, mentionMember, expectedText));
|
|
if (message is not null) return message;
|
|
await Task.Delay(250, cancellationToken);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static async Task<ChatMessageSnapshot?> WaitForMessageAsync(
|
|
string text,
|
|
string session,
|
|
IReadOnlySet<string> baseline,
|
|
TimeSpan timeout,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var deadline = DateTimeOffset.UtcNow + timeout;
|
|
while (DateTimeOffset.UtcNow < deadline)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
using var freshAutomation = new UIA3Automation();
|
|
var window = WechatDoctor.FindWechatWindow(freshAutomation);
|
|
if (window is not null && !IsListeningSession(window, session, independent: false))
|
|
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The conversation changed while confirming the send; no retry was attempted.");
|
|
var match = window is null
|
|
? null
|
|
: ReadVisible(window).LastOrDefault(message =>
|
|
string.Equals(message.Text.ReplaceLineEndings("\n"), text, StringComparison.Ordinal) && !baseline.Contains(message.Fingerprint));
|
|
if (match is not null)
|
|
{
|
|
return match;
|
|
}
|
|
|
|
await Task.Delay(150, cancellationToken);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static IReadOnlyList<ChatMessageSnapshot> ReadVisible(AutomationElement window)
|
|
{
|
|
var list = FindByAutomationId(window, WechatLocators.MessageList)
|
|
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
|
|
return VisibleMessageParser.Parse(list.FindAllDescendants()
|
|
.Where(element => SafeAutomationId(element) == WechatLocators.ChatBubbleItem)
|
|
.Select(element => new ChatMessageSource(SafeName(element), SafeRuntimeId(element))));
|
|
}
|
|
|
|
private static AutomationElement? FindByAutomationId(AutomationElement root, string automationId) =>
|
|
root.FindFirstDescendant(condition => condition.ByAutomationId(automationId));
|
|
|
|
private sealed class ClipboardLease : IDisposable
|
|
{
|
|
private readonly ManualResetEventSlim _ready = new();
|
|
private readonly ManualResetEventSlim _restore = new();
|
|
private readonly Thread _thread;
|
|
private Exception? _error;
|
|
|
|
public ClipboardLease(string content, bool asImage = false, bool asText = false)
|
|
{
|
|
_thread = new Thread(() => Run(content, asImage, asText)) { IsBackground = true };
|
|
_thread.SetApartmentState(ApartmentState.STA);
|
|
_thread.Start();
|
|
if (!_ready.Wait(TimeSpan.FromSeconds(5)))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Timed out while preparing the Windows clipboard.");
|
|
}
|
|
ThrowIfFailed();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_restore.Set();
|
|
if (!_thread.Join(TimeSpan.FromSeconds(5)))
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "Timed out while restoring the Windows clipboard.");
|
|
}
|
|
ThrowIfFailed();
|
|
_ready.Dispose();
|
|
_restore.Dispose();
|
|
}
|
|
|
|
private void Run(string content, bool asImage, bool asText)
|
|
{
|
|
string? text = null;
|
|
StringCollection? files = null;
|
|
System.Drawing.Image? image = null;
|
|
System.Drawing.Image? outgoingImage = null;
|
|
try
|
|
{
|
|
if (Clipboard.ContainsFileDropList()) files = Clipboard.GetFileDropList();
|
|
else if (Clipboard.ContainsImage()) image = (System.Drawing.Image?)Clipboard.GetImage()?.Clone();
|
|
else if (Clipboard.ContainsText()) text = Clipboard.GetText();
|
|
if (asText)
|
|
{
|
|
Clipboard.SetText(content);
|
|
}
|
|
else if (asImage)
|
|
{
|
|
using var source = System.Drawing.Image.FromFile(content);
|
|
outgoingImage = new System.Drawing.Bitmap(source);
|
|
Clipboard.SetImage(outgoingImage);
|
|
}
|
|
else
|
|
{
|
|
Clipboard.SetFileDropList(new StringCollection { content });
|
|
}
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_error = exception;
|
|
_ready.Set();
|
|
return;
|
|
}
|
|
|
|
_ready.Set();
|
|
_restore.Wait();
|
|
try
|
|
{
|
|
if (files is { Count: > 0 }) Clipboard.SetFileDropList(files);
|
|
else if (image is not null) Clipboard.SetImage(image);
|
|
else if (text is not null) Clipboard.SetText(text);
|
|
else Clipboard.Clear();
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
_error = exception;
|
|
}
|
|
finally
|
|
{
|
|
image?.Dispose();
|
|
outgoingImage?.Dispose();
|
|
}
|
|
}
|
|
|
|
private void ThrowIfFailed()
|
|
{
|
|
if (_error is not null)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ClipboardUnavailable, "The Windows clipboard could not be used for input.", _error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void MoveToCenter(AutomationElement element)
|
|
{
|
|
var bounds = element.BoundingRectangle;
|
|
if (bounds.Width <= 0 || bounds.Height <= 0)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target control has no usable bounds.");
|
|
}
|
|
|
|
Mouse.MoveTo(new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2));
|
|
}
|
|
|
|
private static void RightClickMessageBubble(AutomationElement element)
|
|
{
|
|
var bounds = element.BoundingRectangle;
|
|
if (bounds.Width <= 160 || bounds.Height <= 0)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target message has no usable bounds.");
|
|
}
|
|
|
|
// WeChat message rows span the viewport; own-message bubbles are anchored at the right edge.
|
|
Mouse.RightClick(new System.Drawing.Point(bounds.Right - 80, bounds.Top + bounds.Height / 2));
|
|
}
|
|
|
|
private static void ClickCenter(AutomationElement element)
|
|
{
|
|
WechatDesktop.EnsureInputAvailable();
|
|
var bounds = element.BoundingRectangle;
|
|
if (bounds.Width <= 0 || bounds.Height <= 0)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The target control has no clickable bounds.");
|
|
}
|
|
|
|
Mouse.Click(new System.Drawing.Point(bounds.Left + bounds.Width / 2, bounds.Top + bounds.Height / 2), MouseButton.Left);
|
|
}
|
|
|
|
private static void ExecuteInputStep(string step, Action action)
|
|
{
|
|
WechatDesktop.EnsureInputAvailable();
|
|
try
|
|
{
|
|
action();
|
|
}
|
|
catch (WxAgentException)
|
|
{
|
|
throw;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState,
|
|
$"UI input failed at {step} ({exception.GetType().Name}, HRESULT 0x{exception.HResult:X8}).", exception);
|
|
}
|
|
}
|
|
|
|
private static bool SessionNameMatches(string accessibleName, string requestedName) =>
|
|
accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Contains(requestedName, StringComparer.Ordinal);
|
|
|
|
private static string SafeName(AutomationElement? element)
|
|
{
|
|
if (element is null)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
try { return element.Name ?? string.Empty; } catch { return string.Empty; }
|
|
}
|
|
|
|
private static string SafeAutomationId(AutomationElement element)
|
|
{
|
|
try { return element.AutomationId ?? string.Empty; } catch { return string.Empty; }
|
|
}
|
|
|
|
private static string? SafeRuntimeId(AutomationElement element)
|
|
{
|
|
try { return string.Join('.', element.FrameworkAutomationElement.RuntimeId); } catch { return null; }
|
|
}
|
|
|
|
private static ControlType SafeControlType(AutomationElement element)
|
|
{
|
|
try { return element.ControlType; } catch { return ControlType.Custom; }
|
|
}
|
|
}
|