Files
wx-win-agent/node-agent/WxAgent.Windows/WechatChatClient.cs
T
rogee ab9ff389f2
Build web service image / build (push) Successful in 48s
fix authorized remote message reads
2026-09-21 11:18:09 +08:00

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; }
}
}