Initial import
This commit is contained in:
@@ -0,0 +1,889 @@
|
||||
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 class WechatChatClient
|
||||
{
|
||||
private static readonly SemaphoreSlim CommandQueue = new(1, 1);
|
||||
|
||||
public static Task<IReadOnlyList<WechatSessionSnapshot>> ListVisibleSessionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var automation = new UIA3Automation();
|
||||
var window = AttachWindow(automation);
|
||||
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);
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<WechatSessionSearchResult>> SearchSessionsAsync(
|
||||
string query,
|
||||
bool exactOnly,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
||||
await CommandQueue.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
using var automation = new UIA3Automation();
|
||||
var window = AttachWindow(automation);
|
||||
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
|
||||
{
|
||||
box.Text = query;
|
||||
if (!string.Equals(box.Text, query, StringComparison.Ordinal))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The search box did not contain the exact query.");
|
||||
}
|
||||
|
||||
await Task.Delay(500, cancellationToken);
|
||||
var results = WechatSessionParser.ParseSearchResults(
|
||||
window.FindAllDescendants()
|
||||
.Where(element => SafeControlType(element) == ControlType.ListItem)
|
||||
.Select(element => (SafeAutomationId(element), SafeName(element))),
|
||||
query);
|
||||
return exactOnly ? results.Where(result => result.IsExactMatch).ToArray() : results;
|
||||
}
|
||||
finally
|
||||
{
|
||||
box.Text = previous;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CommandQueue.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<WechatSessionSnapshot?> GetCurrentSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var automation = new UIA3Automation();
|
||||
var window = AttachWindow(automation);
|
||||
var element = FindByAutomationId(window, WechatLocators.CurrentChatName);
|
||||
var name = SafeName(element);
|
||||
return Task.FromResult(string.IsNullOrWhiteSpace(name)
|
||||
? null
|
||||
: new WechatSessionSnapshot(name, SafeAutomationId(element!), true));
|
||||
}
|
||||
|
||||
public static async Task<WechatSessionSnapshot> OpenSessionAsync(string name, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
await CommandQueue.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
using var automation = new UIA3Automation();
|
||||
var window = AttachWindow(automation);
|
||||
if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name)
|
||||
{
|
||||
return new WechatSessionSnapshot(name, SafeAutomationId(FindByAutomationId(window, WechatLocators.CurrentChatName)!), true);
|
||||
}
|
||||
|
||||
var target = FindByAutomationId(window, "session_item_" + name);
|
||||
if (target is null)
|
||||
{
|
||||
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.");
|
||||
search.AsTextBox().Text = name;
|
||||
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (DateTimeOffset.UtcNow < deadline && target is null)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
target = window.FindAllDescendants().FirstOrDefault(element =>
|
||||
SafeAutomationId(element).StartsWith("search_item_", StringComparison.Ordinal) && SessionNameMatches(SafeName(element), name));
|
||||
if (target is null) await Task.Delay(150, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
if (target is null)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "The requested session was not found.");
|
||||
}
|
||||
|
||||
ClickCenter(target);
|
||||
if (!await WaitForSessionAsync(window, name, TimeSpan.FromSeconds(10), cancellationToken))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested session did not become active.");
|
||||
}
|
||||
|
||||
return new WechatSessionSnapshot(name, SafeAutomationId(target), true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CommandQueue.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<ChatMessageSnapshot> SendTextAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(text);
|
||||
if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n'))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "M1 text messages must be one line and no longer than 4000 characters.");
|
||||
}
|
||||
|
||||
await CommandQueue.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
using var automation = new UIA3Automation();
|
||||
var window = AttachWindow(automation);
|
||||
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
||||
await Task.Delay(500, cancellationToken);
|
||||
|
||||
var input = FindByAutomationId(window, WechatLocators.ChatInput)
|
||||
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.ChatInput} was not found.");
|
||||
var send = window.FindAllDescendants().FirstOrDefault(element =>
|
||||
SafeControlType(element) == ControlType.Button && SafeName(element) == WechatLocators.Send)
|
||||
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, "Send button was not found.");
|
||||
|
||||
var inputBox = input.AsTextBox();
|
||||
ExecuteInputStep("set-input-value", () => inputBox.Text = text);
|
||||
await Task.Delay(150, cancellationToken);
|
||||
if (!string.Equals(inputBox.Text, text, StringComparison.Ordinal))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The chat input did not contain the exact requested text; send was aborted.");
|
||||
}
|
||||
|
||||
ExecuteInputStep("click-send", () => ClickCenter(send));
|
||||
|
||||
var confirmed = await WaitForMessageAsync(text, 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)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(text);
|
||||
if (text.Length > 4000 || text.Contains('\r') || text.Contains('\n'))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Reply text must be one line and no longer than 4000 characters.");
|
||||
}
|
||||
|
||||
await CommandQueue.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
using var automation = new UIA3Automation();
|
||||
var window = AttachWindow(automation);
|
||||
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
||||
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 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.");
|
||||
input.AsTextBox().Text = text;
|
||||
if (!string.Equals(input.AsTextBox().Text, 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, 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) =>
|
||||
SendAttachmentAsync(path, asImage: false, cancellationToken);
|
||||
|
||||
public static Task<ChatMessageSnapshot> SendImageAsync(string path, CancellationToken cancellationToken) =>
|
||||
SendAttachmentAsync(path, asImage: true, cancellationToken);
|
||||
|
||||
private static async Task<ChatMessageSnapshot> SendAttachmentAsync(string path, bool asImage, CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
||||
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?.Trim();
|
||||
var placeholder = SafeName(input).Trim();
|
||||
if (!string.IsNullOrEmpty(inputText) && !string.Equals(inputText, placeholder, StringComparison.Ordinal))
|
||||
{
|
||||
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 ClipboardFileLease(fullPath, asImage))
|
||||
{
|
||||
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.");
|
||||
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, TimeSpan.FromSeconds(30), cancellationToken);
|
||||
return confirmed ?? throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The attachment selection completed but no new visible message confirmed the send result.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
CommandQueue.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<ChatMessageSnapshot>> ReadHistoryAsync(
|
||||
int maxMessages,
|
||||
int maxScrolls,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
||||
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();
|
||||
window = WechatDoctor.FindWechatWindow(automation) ?? window;
|
||||
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;
|
||||
|
||||
var list = FindByAutomationId(window, WechatLocators.MessageList)
|
||||
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
|
||||
MoveToCenter(list);
|
||||
Mouse.Scroll(5);
|
||||
scrolled++;
|
||||
await Task.Delay(350, cancellationToken);
|
||||
}
|
||||
|
||||
if (scrolled > 0)
|
||||
{
|
||||
Mouse.Scroll(-5d * scrolled);
|
||||
}
|
||||
|
||||
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);
|
||||
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
||||
return ReadVisible(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CommandQueue.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async IAsyncEnumerable<ChatMessageSnapshot> ListenAsync(
|
||||
TimeSpan duration,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive.");
|
||||
}
|
||||
|
||||
using var automation = new UIA3Automation();
|
||||
AutomationElement window;
|
||||
await CommandQueue.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
window = AttachWindow(automation);
|
||||
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CommandQueue.Release();
|
||||
}
|
||||
|
||||
var list = FindByAutomationId(window, WechatLocators.MessageList)
|
||||
?? throw new WxAgentException(WxAgentErrorCode.ControlNotFound, $"Control {WechatLocators.MessageList} was not found.");
|
||||
var signals = Channel.CreateBounded<bool>(new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropWrite });
|
||||
using var subscription = list.RegisterStructureChangedEvent(
|
||||
TreeScope.Subtree,
|
||||
(_, _, _) => signals.Writer.TryWrite(true));
|
||||
var deduper = new BoundedMessageDeduper();
|
||||
foreach (var message in ReadVisible(window))
|
||||
{
|
||||
deduper.TryAdd(message.Fingerprint);
|
||||
}
|
||||
|
||||
var deadline = DateTimeOffset.UtcNow + duration;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var remaining = deadline - DateTimeOffset.UtcNow;
|
||||
var delay = Task.Delay(remaining < TimeSpan.FromSeconds(2) ? remaining : TimeSpan.FromSeconds(2), cancellationToken);
|
||||
var eventReady = signals.Reader.WaitToReadAsync(cancellationToken).AsTask();
|
||||
await Task.WhenAny(delay, eventReady);
|
||||
while (signals.Reader.TryRead(out _))
|
||||
{
|
||||
}
|
||||
|
||||
foreach (var message in ReadVisible(window))
|
||||
{
|
||||
if (deduper.TryAdd(message.Fingerprint))
|
||||
{
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async IAsyncEnumerable<MessageEvent> ListenEventsAsync(
|
||||
TimeSpan duration,
|
||||
string? checkpointPath,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken,
|
||||
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>>? callbacks = null)
|
||||
{
|
||||
if (duration <= TimeSpan.Zero)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Listen duration must be positive.");
|
||||
}
|
||||
|
||||
const string session = WechatLocators.FileTransferAssistant;
|
||||
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;
|
||||
var announceReconnect = recoveredScan;
|
||||
var elapsed = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
while (elapsed.Elapsed < duration)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
IReadOnlyList<ChatMessageSnapshot>? visible = null;
|
||||
try
|
||||
{
|
||||
window = WechatDoctor.FindWechatWindow(automation);
|
||||
if (window is not null)
|
||||
{
|
||||
if (!connected)
|
||||
{
|
||||
await CommandQueue.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await OpenFileTransferAssistantCoreAsync(window, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CommandQueue.Release();
|
||||
}
|
||||
|
||||
subscription?.Dispose();
|
||||
var list = FindByAutomationId(window, WechatLocators.MessageList);
|
||||
subscription = list?.RegisterStructureChangedEvent(
|
||||
TreeScope.Subtree,
|
||||
(_, _, _) => signals.Writer.TryWrite(true));
|
||||
connected = true;
|
||||
}
|
||||
visible = ReadVisible(window);
|
||||
}
|
||||
else
|
||||
{
|
||||
connected = false;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private static AutomationElement AttachWindow(UIA3Automation automation)
|
||||
{
|
||||
var window = WechatDoctor.FindWechatWindow(automation);
|
||||
if (window is null)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat main window was not found in the current interactive session.");
|
||||
}
|
||||
|
||||
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(
|
||||
HashSet<string> previous,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (DateTimeOffset.UtcNow < deadline)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
using var automation = new UIA3Automation();
|
||||
var window = WechatDoctor.FindWechatWindow(automation);
|
||||
var message = window is null ? null : ReadVisible(window).LastOrDefault(item => !previous.Contains(item.Fingerprint));
|
||||
if (message is not null) return message;
|
||||
await Task.Delay(250, cancellationToken);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<ChatMessageSnapshot?> WaitForMessageAsync(
|
||||
string text,
|
||||
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);
|
||||
var match = window is null
|
||||
? null
|
||||
: ReadVisible(window).LastOrDefault(message => string.Equals(message.Text, text, StringComparison.Ordinal));
|
||||
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 ClipboardFileLease : IDisposable
|
||||
{
|
||||
private readonly ManualResetEventSlim _ready = new();
|
||||
private readonly ManualResetEventSlim _restore = new();
|
||||
private readonly Thread _thread;
|
||||
private Exception? _error;
|
||||
|
||||
public ClipboardFileLease(string fullPath, bool asImage)
|
||||
{
|
||||
_thread = new Thread(() => Run(fullPath, asImage)) { 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 fullPath, bool asImage)
|
||||
{
|
||||
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 (asImage)
|
||||
{
|
||||
using var source = System.Drawing.Image.FromFile(fullPath);
|
||||
outgoingImage = new System.Drawing.Bitmap(source);
|
||||
Clipboard.SetImage(outgoingImage);
|
||||
}
|
||||
else
|
||||
{
|
||||
Clipboard.SetFileDropList(new StringCollection { fullPath });
|
||||
}
|
||||
}
|
||||
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 attachment sending.", _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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, $"UI input failed at {step}.", 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; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user