Initial import
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum ChatMessageType
|
||||
{
|
||||
Text,
|
||||
Image,
|
||||
File,
|
||||
Video,
|
||||
Voice,
|
||||
Link,
|
||||
Quote,
|
||||
System
|
||||
}
|
||||
|
||||
public sealed record QuotedMessageSnapshot(string? Sender, string Text);
|
||||
|
||||
public sealed record ChatMessageSource(string Text, string? SourceId = null);
|
||||
|
||||
public sealed record ChatMessageSnapshot(
|
||||
string Text,
|
||||
string Fingerprint,
|
||||
int VisibleIndex,
|
||||
ChatMessageType Type,
|
||||
QuotedMessageSnapshot? Quote = null);
|
||||
|
||||
public static class VisibleMessageParser
|
||||
{
|
||||
public static IReadOnlyList<ChatMessageSnapshot> Parse(IEnumerable<string> accessibleNames) =>
|
||||
Parse(accessibleNames.Select(text => new ChatMessageSource(text)));
|
||||
|
||||
public static IReadOnlyList<ChatMessageSnapshot> Parse(IEnumerable<ChatMessageSource> sources)
|
||||
{
|
||||
var occurrences = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var messages = new List<ChatMessageSnapshot>();
|
||||
foreach (var source in sources.Where(value => !string.IsNullOrWhiteSpace(value.Text)))
|
||||
{
|
||||
var text = source.Text;
|
||||
var occurrence = occurrences.TryGetValue(text, out var count) ? count : 0;
|
||||
occurrences[text] = occurrence + 1;
|
||||
var identity = $"{occurrence}:{text}";
|
||||
var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant();
|
||||
var (replyText, quote) = ParseQuote(text);
|
||||
var type = quote is null ? Classify(replyText) : ChatMessageType.Quote;
|
||||
messages.Add(new ChatMessageSnapshot(replyText, fingerprint, messages.Count, type, quote));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
public static (string Text, QuotedMessageSnapshot? Quote) ParseQuote(string accessibleName)
|
||||
{
|
||||
var lines = accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (lines.Length >= 2 && lines[^1].StartsWith("引用 ", StringComparison.Ordinal))
|
||||
{
|
||||
var descriptor = lines[^1];
|
||||
var messageMarker = descriptor.IndexOf(" 的消息", StringComparison.Ordinal);
|
||||
var separator = descriptor.IndexOfAny([':', ':']);
|
||||
if (messageMarker > 3 && separator > messageMarker)
|
||||
{
|
||||
var sender = descriptor[3..messageMarker].Trim();
|
||||
var quoteText = descriptor[(separator + 1)..].Trim();
|
||||
return (string.Join('\n', lines[..^1]), new QuotedMessageSnapshot(sender, quoteText));
|
||||
}
|
||||
}
|
||||
|
||||
var quoteIndex = Array.FindIndex(lines, line =>
|
||||
string.Equals(line, "引用", StringComparison.Ordinal) || line.StartsWith("引用:", StringComparison.Ordinal));
|
||||
if (quoteIndex < 0 || lines.Length - quoteIndex < 3)
|
||||
{
|
||||
return (accessibleName, null);
|
||||
}
|
||||
|
||||
var marker = lines[quoteIndex];
|
||||
var legacySender = marker.Length > 3 ? marker[3..].Trim() : null;
|
||||
var legacyQuoteText = string.Join('\n', lines[(quoteIndex + 1)..^1]);
|
||||
return (lines[^1], new QuotedMessageSnapshot(string.IsNullOrWhiteSpace(legacySender) ? null : legacySender, legacyQuoteText));
|
||||
}
|
||||
|
||||
public static ChatMessageType Classify(string text)
|
||||
{
|
||||
if (text is "[图片]" or "图片" || text.StartsWith("图片\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.Image;
|
||||
}
|
||||
|
||||
if (text is "[视频]" or "视频" || text.StartsWith("视频\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.Video;
|
||||
}
|
||||
|
||||
if (text is "[语音]" or "语音" || text.StartsWith("语音\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.Voice;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(text, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
|
||||
{
|
||||
return ChatMessageType.Link;
|
||||
}
|
||||
|
||||
if (text.StartsWith("文件\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.File;
|
||||
}
|
||||
|
||||
if (!text.Contains(Path.DirectorySeparatorChar) && !text.Contains(Path.AltDirectorySeparatorChar) &&
|
||||
Path.GetExtension(text) is { Length: > 1 } extension && extension.Length <= 10)
|
||||
{
|
||||
return ChatMessageType.File;
|
||||
}
|
||||
|
||||
return text.StartsWith("[", StringComparison.Ordinal) && text.EndsWith("]", StringComparison.Ordinal)
|
||||
? ChatMessageType.System
|
||||
: ChatMessageType.Text;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BoundedMessageDeduper(int capacity = 2048)
|
||||
{
|
||||
private readonly Queue<string> _order = new();
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TryAdd(string fingerprint)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
|
||||
if (!_seen.Add(fingerprint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_order.Enqueue(fingerprint);
|
||||
if (_order.Count > capacity)
|
||||
{
|
||||
_seen.Remove(_order.Dequeue());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum KeyBindingConfidence
|
||||
{
|
||||
PageHmacVerified
|
||||
}
|
||||
|
||||
public sealed record DatabaseKeyEvidence(
|
||||
string RelativePath,
|
||||
string Salt,
|
||||
string EncKey,
|
||||
int SourceProcessId,
|
||||
DateTimeOffset VerifiedAt,
|
||||
KeyBindingConfidence Confidence);
|
||||
|
||||
public sealed record AccountKeySet(
|
||||
string AccountRootFingerprint,
|
||||
string AccountRootPath,
|
||||
string? WechatVersion,
|
||||
DateTimeOffset LastSeenAt,
|
||||
IReadOnlyList<DatabaseKeyEvidence> Databases);
|
||||
|
||||
public static class AccountKeySetMerge
|
||||
{
|
||||
public static IReadOnlyList<AccountKeySet> Merge(
|
||||
IReadOnlyList<AccountKeySet> existing,
|
||||
IReadOnlyList<AccountKeySet> incoming)
|
||||
{
|
||||
var accounts = existing.ToDictionary(account => account.AccountRootFingerprint, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var current in incoming)
|
||||
{
|
||||
if (!accounts.TryGetValue(current.AccountRootFingerprint, out var previous))
|
||||
{
|
||||
accounts[current.AccountRootFingerprint] = current;
|
||||
continue;
|
||||
}
|
||||
|
||||
var databases = previous.Databases.ToDictionary(database => database.RelativePath, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var database in current.Databases)
|
||||
{
|
||||
databases[database.RelativePath] = database;
|
||||
}
|
||||
|
||||
accounts[current.AccountRootFingerprint] = current with
|
||||
{
|
||||
WechatVersion = current.WechatVersion ?? previous.WechatVersion,
|
||||
Databases = databases.Values.OrderBy(database => database.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
return accounts.Values.OrderBy(account => account.AccountRootFingerprint, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static class AccountRootFingerprint
|
||||
{
|
||||
public static string Create(string accountRootPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(accountRootPath);
|
||||
var normalized = accountRootPath.Replace('/', '\\').TrimEnd('\\').ToUpperInvariant();
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum MessageEventKind
|
||||
{
|
||||
MessageReceived,
|
||||
Reconnected
|
||||
}
|
||||
|
||||
public sealed record MessageEvent(
|
||||
string EventId,
|
||||
MessageEventKind Kind,
|
||||
string Session,
|
||||
ChatMessageSnapshot? Message,
|
||||
DateTimeOffset ObservedAt,
|
||||
bool Recovered);
|
||||
|
||||
public sealed record ListenerCheckpoint(
|
||||
int Version,
|
||||
string Session,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyList<string> Fingerprints);
|
||||
|
||||
public sealed class MessageEventState
|
||||
{
|
||||
private readonly int _capacity;
|
||||
private readonly Queue<string> _order = new();
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
|
||||
public MessageEventState(int capacity = 4096, ListenerCheckpoint? checkpoint = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
|
||||
_capacity = capacity;
|
||||
if (checkpoint is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var fingerprint in checkpoint.Fingerprints.TakeLast(capacity))
|
||||
{
|
||||
Add(fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
public MessageEvent? TryCreateMessage(
|
||||
string session,
|
||||
ChatMessageSnapshot message,
|
||||
DateTimeOffset observedAt,
|
||||
bool recovered)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(session);
|
||||
if (!Add(message.Fingerprint))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new MessageEvent(
|
||||
message.Fingerprint,
|
||||
MessageEventKind.MessageReceived,
|
||||
session,
|
||||
message,
|
||||
observedAt,
|
||||
recovered);
|
||||
}
|
||||
|
||||
public ListenerCheckpoint CreateCheckpoint(string session, DateTimeOffset updatedAt) =>
|
||||
new(2, session, updatedAt, _order.ToArray());
|
||||
|
||||
private bool Add(string fingerprint)
|
||||
{
|
||||
if (!_seen.Add(fingerprint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_order.Enqueue(fingerprint);
|
||||
if (_order.Count > _capacity)
|
||||
{
|
||||
_seen.Remove(_order.Dequeue());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MessageCallbackFailure(int CallbackIndex, string ErrorType);
|
||||
|
||||
public static class MessageCallbackDispatcher
|
||||
{
|
||||
public static async Task<IReadOnlyList<MessageCallbackFailure>> DispatchAsync(
|
||||
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>> callbacks,
|
||||
MessageEvent messageEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<MessageCallbackFailure>();
|
||||
for (var index = 0; index < callbacks.Count; index++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await callbacks[index](messageEvent, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failures.Add(new MessageCallbackFailure(index, exception.GetType().Name));
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public static class SqlCipherPageVerifier
|
||||
{
|
||||
public const int PageSize = 4096;
|
||||
public const int SaltSize = 16;
|
||||
public const int KeySize = 32;
|
||||
public const int HmacSize = 64;
|
||||
|
||||
public static bool Verify(ReadOnlySpan<byte> page, ReadOnlySpan<byte> key, uint pageNumber = 1)
|
||||
{
|
||||
if (page.Length != PageSize || key.Length != KeySize || pageNumber == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<byte> hmacSalt = stackalloc byte[SaltSize];
|
||||
for (var index = 0; index < SaltSize; index++)
|
||||
{
|
||||
hmacSalt[index] = (byte)(page[index] ^ 0x3a);
|
||||
}
|
||||
|
||||
Span<byte> hmacKey = stackalloc byte[KeySize];
|
||||
Rfc2898DeriveBytes.Pbkdf2(key, hmacSalt, hmacKey, 2, HashAlgorithmName.SHA512);
|
||||
|
||||
var authenticatedLength = PageSize - SaltSize - HmacSize;
|
||||
var authenticated = new byte[authenticatedLength + sizeof(uint)];
|
||||
page.Slice(SaltSize, authenticatedLength).CopyTo(authenticated);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(authenticated.AsSpan(authenticatedLength), pageNumber);
|
||||
|
||||
var computed = HMACSHA512.HashData(hmacKey, authenticated);
|
||||
var valid = CryptographicOperations.FixedTimeEquals(computed, page[^HmacSize..]);
|
||||
CryptographicOperations.ZeroMemory(hmacKey);
|
||||
CryptographicOperations.ZeroMemory(authenticated);
|
||||
CryptographicOperations.ZeroMemory(computed);
|
||||
return valid;
|
||||
}
|
||||
|
||||
public static bool VerifyHexKey(ReadOnlySpan<byte> page, string hexKey, uint pageNumber = 1)
|
||||
{
|
||||
if (hexKey.Length != KeySize * 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
key = Convert.FromHexString(hexKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Verify(page, key, pageNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record UiNodeSnapshot(
|
||||
string ControlType,
|
||||
string Name,
|
||||
string AutomationId,
|
||||
bool IsEnabled,
|
||||
string Bounds,
|
||||
IReadOnlyList<UiNodeSnapshot> Children);
|
||||
|
||||
public static class UiSnapshotSanitizer
|
||||
{
|
||||
private static readonly HashSet<string> SafeAutomationIds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"MainView",
|
||||
"MainView.main_tabbar",
|
||||
"MainView.main_tabbar.tabbar_setting",
|
||||
"session_list",
|
||||
"chat_message_page",
|
||||
"chat_message_list",
|
||||
"chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view",
|
||||
"chat_input_field",
|
||||
"chat_input_field.qt_scrollarea_viewport",
|
||||
"tool_bar_accessible"
|
||||
};
|
||||
|
||||
public static string SanitizeName(string? value) => Sanitize(value, [], "text");
|
||||
|
||||
public static string SanitizeAutomationId(string? value) => Sanitize(value, SafeAutomationIds, "id");
|
||||
|
||||
private static string Sanitize(string? value, HashSet<string> allowList, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || allowList.Contains(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
return $"[{label}:{value.Length}:{Convert.ToHexString(bytes.AsSpan(0, 4)).ToLowerInvariant()}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record WcdbKeyCandidate(string EncKey, string? SaltHint);
|
||||
|
||||
public sealed class WcdbKeyCandidateScanner
|
||||
{
|
||||
private const int KeyHexLength = 64;
|
||||
private const int SaltHexLength = 32;
|
||||
|
||||
private readonly StringBuilder _keyPrefix = new(KeyHexLength);
|
||||
private readonly char[] _saltSuffix = new char[SaltHexLength];
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
private long _runLength;
|
||||
|
||||
public IReadOnlyList<WcdbKeyCandidate> Feed(ReadOnlySpan<byte> bytes, bool finalBlock = false)
|
||||
{
|
||||
var found = new List<WcdbKeyCandidate>();
|
||||
foreach (var value in bytes)
|
||||
{
|
||||
if (IsAsciiHex(value))
|
||||
{
|
||||
AppendHex(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
CompleteRun(found);
|
||||
}
|
||||
}
|
||||
|
||||
if (finalBlock)
|
||||
{
|
||||
CompleteRun(found);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private void AppendHex(byte value)
|
||||
{
|
||||
var character = char.ToLowerInvariant((char)value);
|
||||
if (_runLength < KeyHexLength)
|
||||
{
|
||||
_keyPrefix.Append(character);
|
||||
}
|
||||
|
||||
_saltSuffix[_runLength % SaltHexLength] = character;
|
||||
_runLength++;
|
||||
}
|
||||
|
||||
private void CompleteRun(List<WcdbKeyCandidate> found)
|
||||
{
|
||||
if (_runLength >= KeyHexLength)
|
||||
{
|
||||
var key = _keyPrefix.ToString();
|
||||
var salt = _runLength >= KeyHexLength + SaltHexLength ? GetSaltSuffix() : null;
|
||||
if (_seen.Add(key + ":" + salt))
|
||||
{
|
||||
found.Add(new WcdbKeyCandidate(key, salt));
|
||||
}
|
||||
}
|
||||
|
||||
_keyPrefix.Clear();
|
||||
_runLength = 0;
|
||||
}
|
||||
|
||||
private string GetSaltSuffix()
|
||||
{
|
||||
var salt = new char[SaltHexLength];
|
||||
var start = (int)(_runLength % SaltHexLength);
|
||||
for (var index = 0; index < salt.Length; index++)
|
||||
{
|
||||
salt[index] = _saltSuffix[(start + index) % SaltHexLength];
|
||||
}
|
||||
|
||||
return new string(salt);
|
||||
}
|
||||
|
||||
private static bool IsAsciiHex(byte value) =>
|
||||
value is >= (byte)'0' and <= (byte)'9' or
|
||||
>= (byte)'a' and <= (byte)'f' or
|
||||
>= (byte)'A' and <= (byte)'F';
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public static class WechatLocators
|
||||
{
|
||||
public const string MainView = "MainView";
|
||||
public const string SessionList = "session_list";
|
||||
public const string ChatPage = "chat_message_page";
|
||||
public const string MessageList = "chat_message_list";
|
||||
public const string ChatInput = "chat_input_field";
|
||||
public const string ToolBar = "tool_bar_accessible";
|
||||
public const string CurrentChatName = "content_view.top_content_view.title_h_view.left_v_view.left_content_v_view.left_ui_.big_title_line_h_view.current_chat_name_label";
|
||||
public const string ChatBubbleItem = "chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view";
|
||||
public const string FileTransferAssistant = "文件传输助手";
|
||||
public const string Search = "搜索";
|
||||
public const string Send = "发送";
|
||||
public const string FileTransferAssistantSearchResult = "search_item_function_文件传输助手";
|
||||
|
||||
public static IReadOnlyList<string> RequiredAutomationIds { get; } =
|
||||
[MainView, SessionList, ChatPage, MessageList, ChatInput, ToolBar];
|
||||
|
||||
public static WxAgentErrorCode? ClassifyFoundWindow(IReadOnlyDictionary<string, bool> controls)
|
||||
{
|
||||
if (controls.Values.All(found => !found))
|
||||
{
|
||||
return WxAgentErrorCode.WechatNotLoggedIn;
|
||||
}
|
||||
|
||||
if (!controls.GetValueOrDefault(MainView) || !controls.GetValueOrDefault(SessionList))
|
||||
{
|
||||
return WxAgentErrorCode.UiStructureChanged;
|
||||
}
|
||||
|
||||
var chatControls = new[] { ChatPage, MessageList, ChatInput, ToolBar };
|
||||
var visibleChatControls = chatControls.Count(id => controls.GetValueOrDefault(id));
|
||||
return visibleChatControls is 0 or 4 ? null : WxAgentErrorCode.UiStructureChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record WechatSessionSnapshot(string Name, string AutomationId, bool IsCurrent);
|
||||
|
||||
public sealed record WechatSessionSearchResult(string Name, string AutomationId, string? Category, bool IsExactMatch);
|
||||
|
||||
public static class WechatSessionParser
|
||||
{
|
||||
public static IReadOnlyList<WechatSessionSnapshot> Parse(
|
||||
IEnumerable<(string AutomationId, string Name)> elements,
|
||||
string? currentName)
|
||||
{
|
||||
return elements
|
||||
.Where(element => element.AutomationId.StartsWith("session_item_", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(element.Name))
|
||||
.DistinctBy(element => element.AutomationId, StringComparer.Ordinal)
|
||||
.Select(element => new WechatSessionSnapshot(
|
||||
element.Name,
|
||||
element.AutomationId,
|
||||
string.Equals(element.Name, currentName, StringComparison.Ordinal)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WechatSessionSearchResult> ParseSearchResults(
|
||||
IEnumerable<(string AutomationId, string AccessibleName)> elements,
|
||||
string query)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
||||
return elements
|
||||
.Where(element => element.AutomationId.StartsWith("search_item_", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(element.AccessibleName))
|
||||
.DistinctBy(element => element.AutomationId, StringComparer.Ordinal)
|
||||
.Select(element =>
|
||||
{
|
||||
var lines = element.AccessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var exact = lines.FirstOrDefault(line => string.Equals(line, query, StringComparison.Ordinal));
|
||||
var name = exact ?? lines.Last();
|
||||
var category = lines.Length > 1 && !string.Equals(lines[0], name, StringComparison.Ordinal) ? lines[0] : null;
|
||||
return new WechatSessionSearchResult(name, element.AutomationId, category, exact is not null);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum WxAgentErrorCode
|
||||
{
|
||||
WechatNotRunning,
|
||||
WechatNotLoggedIn,
|
||||
WindowNotFound,
|
||||
ControlNotFound,
|
||||
UiStructureChanged,
|
||||
Timeout,
|
||||
PermissionMismatch,
|
||||
SessionLocked,
|
||||
UnsupportedWechatVersion,
|
||||
ResultUnconfirmed,
|
||||
OperationCancelled,
|
||||
InvalidOperationState,
|
||||
DataRootNotFound,
|
||||
ProcessAccessDenied,
|
||||
DatabaseKeyNotFound,
|
||||
DatabaseOpenFailed,
|
||||
ClipboardUnavailable,
|
||||
InvalidArgument
|
||||
}
|
||||
|
||||
public sealed class WxAgentException(WxAgentErrorCode code, string message, Exception? innerException = null)
|
||||
: Exception(message, innerException)
|
||||
{
|
||||
public WxAgentErrorCode Code { get; } = code;
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using WxAgent.Core;
|
||||
using WxAgent.Windows;
|
||||
|
||||
var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
|
||||
jsonOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
var jsonLineOptions = new JsonSerializerOptions();
|
||||
jsonLineOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
using var shutdown = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, eventArgs) =>
|
||||
{
|
||||
eventArgs.Cancel = true;
|
||||
shutdown.Cancel();
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (args.Length == 0 || args[0] is "help" or "--help" or "-h")
|
||||
{
|
||||
PrintHelp();
|
||||
return 0;
|
||||
}
|
||||
|
||||
var defaultTimeoutSeconds = ValidateCommandLine(args);
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(GetTimeoutSeconds(args, defaultTimeoutSeconds)));
|
||||
var cancellationToken = timeout.Token;
|
||||
|
||||
switch (args[0])
|
||||
{
|
||||
case "doctor":
|
||||
{
|
||||
var report = WechatDoctor.Run(cancellationToken);
|
||||
WriteJson(report);
|
||||
return report.Errors.Count == 0 ? 0 : 2;
|
||||
}
|
||||
case "inspect-ui":
|
||||
{
|
||||
var output = GetRequiredOption(args, "--output");
|
||||
var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken);
|
||||
WriteJson(new { output = Path.GetFullPath(output), nodes = CountNodes(snapshot), sanitized = true });
|
||||
return 0;
|
||||
}
|
||||
case "smoke":
|
||||
{
|
||||
var output = GetOption(args, "--output") ?? Path.Combine("artifacts", "ui-tree.json");
|
||||
var report = WechatDoctor.Run(cancellationToken);
|
||||
var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken);
|
||||
var marker = $"wxagent-smoke-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
|
||||
var confirmed = await WechatChatClient.SendTextAsync(marker, cancellationToken);
|
||||
WriteJson(new
|
||||
{
|
||||
scope = "M1",
|
||||
doctorErrors = report.Errors,
|
||||
output = Path.GetFullPath(output),
|
||||
nodes = CountNodes(snapshot),
|
||||
sanitized = true,
|
||||
sentTo = "file-transfer-assistant",
|
||||
confirmed.Fingerprint,
|
||||
confirmed = true
|
||||
});
|
||||
return report.Errors.Count == 0 ? 0 : 2;
|
||||
}
|
||||
case "chat" when args[1] == "send":
|
||||
{
|
||||
var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken);
|
||||
WriteJson(ToMessageOutput(message, includeContent: false));
|
||||
return 0;
|
||||
}
|
||||
case "chat" when args[1] == "reply-latest":
|
||||
{
|
||||
var message = await WechatChatClient.ReplyToLatestAsync(GetRequiredOption(args, "--text"), cancellationToken);
|
||||
WriteJson(ToMessageOutput(message, includeContent: false));
|
||||
return 0;
|
||||
}
|
||||
case "chat" when args[1] is "send-file" or "send-image":
|
||||
{
|
||||
var path = GetRequiredOption(args, "--path");
|
||||
if (args[1] == "send-image" && !IsImagePath(path))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "send-image accepts png, jpg, jpeg, gif, bmp, or webp files.");
|
||||
}
|
||||
|
||||
var message = args[1] == "send-image"
|
||||
? await WechatChatClient.SendImageAsync(path, cancellationToken)
|
||||
: await WechatChatClient.SendFileAsync(path, cancellationToken);
|
||||
WriteJson(ToMessageOutput(message, includeContent: false));
|
||||
return 0;
|
||||
}
|
||||
case "chat" when args[1] == "read":
|
||||
{
|
||||
var includeContent = HasOption(args, "--include-content");
|
||||
var limit = GetPositiveIntOption(args, "--limit", 20, 100);
|
||||
var messages = await WechatChatClient.ReadVisibleAsync(cancellationToken);
|
||||
WriteJson(new { messages = messages.TakeLast(limit).Select(message => ToMessageOutput(message, includeContent)) });
|
||||
return 0;
|
||||
}
|
||||
case "chat" when args[1] == "history":
|
||||
{
|
||||
var includeContent = HasOption(args, "--include-content");
|
||||
var limit = GetPositiveIntOption(args, "--limit", 100, 1000);
|
||||
var scrolls = GetPositiveIntOption(args, "--scrolls", 10, 100, allowZero: true);
|
||||
var messages = await WechatChatClient.ReadHistoryAsync(limit, scrolls, cancellationToken);
|
||||
WriteJson(new { messages = messages.Select(message => ToMessageOutput(message, includeContent)) });
|
||||
return 0;
|
||||
}
|
||||
case "chat" when args[1] == "monitor":
|
||||
{
|
||||
var includeContent = HasOption(args, "--include-content");
|
||||
var seconds = GetPositiveIntOption(args, "--seconds", 60, 86400);
|
||||
var stateFile = GetOption(args, "--state-file");
|
||||
await foreach (var messageEvent in WechatChatClient.ListenEventsAsync(
|
||||
TimeSpan.FromSeconds(seconds),
|
||||
stateFile,
|
||||
cancellationToken))
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(ToEventOutput(messageEvent, includeContent), jsonLineOptions));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case "chat" when args[1] == "listen":
|
||||
{
|
||||
var includeContent = HasOption(args, "--include-content");
|
||||
var seconds = GetPositiveIntOption(args, "--seconds", 30, 300);
|
||||
var messages = new List<ChatMessageSnapshot>();
|
||||
await foreach (var message in WechatChatClient.ListenAsync(TimeSpan.FromSeconds(seconds), cancellationToken))
|
||||
{
|
||||
messages.Add(message);
|
||||
}
|
||||
|
||||
WriteJson(new { durationSeconds = seconds, messages = messages.Select(message => ToMessageOutput(message, includeContent)) });
|
||||
return 0;
|
||||
}
|
||||
case "session" when args[1] == "list":
|
||||
{
|
||||
WriteJson(new { sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken) });
|
||||
return 0;
|
||||
}
|
||||
case "session" when args[1] == "search":
|
||||
{
|
||||
var query = GetRequiredOption(args, "--query");
|
||||
var results = await WechatChatClient.SearchSessionsAsync(query, HasOption(args, "--exact"), cancellationToken);
|
||||
WriteJson(new { query, results });
|
||||
return 0;
|
||||
}
|
||||
case "session" when args[1] == "current":
|
||||
{
|
||||
WriteJson(new { session = await WechatChatClient.GetCurrentSessionAsync(cancellationToken) });
|
||||
return 0;
|
||||
}
|
||||
case "session" when args[1] == "open":
|
||||
{
|
||||
WriteJson(await WechatChatClient.OpenSessionAsync(GetRequiredOption(args, "--name"), cancellationToken));
|
||||
return 0;
|
||||
}
|
||||
case "db" when args[1] == "scan":
|
||||
{
|
||||
var dataRoot = GetOption(args, "--data-root");
|
||||
var result = WechatDatabaseScanner.Scan(dataRoot, cancellationToken);
|
||||
string? savedTo = null;
|
||||
if (HasOption(args, "--save"))
|
||||
{
|
||||
savedTo = GetOption(args, "--key-file") ?? DatabaseKeyStore.DefaultPath;
|
||||
var existing = await DatabaseKeyStore.LoadAsync(savedTo, cancellationToken);
|
||||
var merged = AccountKeySetMerge.Merge(existing, result.Accounts);
|
||||
await DatabaseKeyStore.SaveAsync(merged, savedTo, cancellationToken);
|
||||
}
|
||||
|
||||
WriteJson(new
|
||||
{
|
||||
result.ProcessCount,
|
||||
result.CandidateCount,
|
||||
result.DatabaseCount,
|
||||
verifiedDatabaseCount = result.Accounts.Sum(account => account.Databases.Count),
|
||||
savedTo,
|
||||
accounts = result.Accounts.Select(account => new
|
||||
{
|
||||
account.AccountRootFingerprint,
|
||||
account.WechatVersion,
|
||||
databases = account.Databases.Select(database => new
|
||||
{
|
||||
database.RelativePath,
|
||||
database.SourceProcessId,
|
||||
database.Confidence,
|
||||
database.VerifiedAt
|
||||
})
|
||||
})
|
||||
});
|
||||
return result.Accounts.Any(account => account.Databases.Count > 0) ? 0 : 3;
|
||||
}
|
||||
case "db" when args[1] == "status":
|
||||
{
|
||||
var keyFile = GetOption(args, "--key-file");
|
||||
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken);
|
||||
WriteJson(new
|
||||
{
|
||||
keyFile = Path.GetFullPath(keyFile ?? DatabaseKeyStore.DefaultPath),
|
||||
accounts = accounts.Select(account => new
|
||||
{
|
||||
account.AccountRootFingerprint,
|
||||
account.WechatVersion,
|
||||
databaseCount = account.Databases.Count,
|
||||
databases = account.Databases.Select(database => new { database.RelativePath, database.Confidence, database.VerifiedAt })
|
||||
})
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
case "db" when args[1] == "query":
|
||||
{
|
||||
var accountId = GetRequiredOption(args, "--account");
|
||||
var relativePath = GetRequiredOption(args, "--database").Replace('\\', '/');
|
||||
var keyFile = GetOption(args, "--key-file");
|
||||
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken);
|
||||
var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store.");
|
||||
var database = account.Databases.SingleOrDefault(item => string.Equals(item.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Database path was not found for the selected account.");
|
||||
var fullPath = Path.GetFullPath(Path.Combine(account.AccountRootPath, database.RelativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
var accountRoot = Path.GetFullPath(account.AccountRootPath) + Path.DirectorySeparatorChar;
|
||||
if (!fullPath.StartsWith(accountRoot, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database path escapes the account root.");
|
||||
}
|
||||
|
||||
var metadata = await SqlCipherDatabaseReader.ReadMetadataAsync(fullPath, database.EncKey, cancellationToken);
|
||||
WriteJson(new { account = account.AccountRootFingerprint, database = database.RelativePath, metadata });
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help.");
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!shutdown.IsCancellationRequested)
|
||||
{
|
||||
WriteJson(new { error = WxAgentErrorCode.Timeout.ToString(), message = "Operation timed out." });
|
||||
return 124;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
WriteJson(new { error = WxAgentErrorCode.OperationCancelled.ToString(), message = "Operation cancelled." });
|
||||
return 130;
|
||||
}
|
||||
catch (WxAgentException exception)
|
||||
{
|
||||
WriteJson(new { error = exception.Code.ToString(), exception.Message });
|
||||
return 1;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
WriteJson(new { error = WxAgentErrorCode.InvalidOperationState.ToString(), message = exception.Message });
|
||||
return 1;
|
||||
}
|
||||
|
||||
void WriteJson<T>(T value) => Console.WriteLine(JsonSerializer.Serialize(value, jsonOptions));
|
||||
|
||||
static int ValidateCommandLine(string[] values)
|
||||
{
|
||||
if (values[0] == "doctor")
|
||||
{
|
||||
ValidateOptions(values, 1, ["--timeout"], []);
|
||||
return 30;
|
||||
}
|
||||
|
||||
if (values[0] == "inspect-ui")
|
||||
{
|
||||
ValidateOptions(values, 1, ["--output", "--timeout"], []);
|
||||
return 30;
|
||||
}
|
||||
|
||||
if (values[0] == "smoke")
|
||||
{
|
||||
ValidateOptions(values, 1, ["--output", "--timeout"], []);
|
||||
return 30;
|
||||
}
|
||||
|
||||
if (values[0] == "chat" && values.Length >= 2)
|
||||
{
|
||||
switch (values[1])
|
||||
{
|
||||
case "send":
|
||||
ValidateOptions(values, 2, ["--text", "--timeout"], []);
|
||||
return 30;
|
||||
case "reply-latest":
|
||||
ValidateOptions(values, 2, ["--text", "--timeout"], []);
|
||||
return 60;
|
||||
case "send-file":
|
||||
case "send-image":
|
||||
ValidateOptions(values, 2, ["--path", "--timeout"], []);
|
||||
return 60;
|
||||
case "read":
|
||||
ValidateOptions(values, 2, ["--limit", "--timeout"], ["--include-content"]);
|
||||
return 30;
|
||||
case "history":
|
||||
ValidateOptions(values, 2, ["--limit", "--scrolls", "--timeout"], ["--include-content"]);
|
||||
return 60;
|
||||
case "monitor":
|
||||
ValidateOptions(values, 2, ["--seconds", "--state-file", "--timeout"], ["--include-content"]);
|
||||
return GetPositiveIntOption(values, "--seconds", 60, 86400) + 30;
|
||||
case "listen":
|
||||
ValidateOptions(values, 2, ["--seconds", "--timeout"], ["--include-content"]);
|
||||
return GetPositiveIntOption(values, "--seconds", 30, 300) + 10;
|
||||
default:
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown chat command. Run WxAgent.Host help.");
|
||||
}
|
||||
}
|
||||
|
||||
if (values[0] == "session" && values.Length >= 2)
|
||||
{
|
||||
switch (values[1])
|
||||
{
|
||||
case "list":
|
||||
case "current":
|
||||
ValidateOptions(values, 2, ["--timeout"], []);
|
||||
return 30;
|
||||
case "search":
|
||||
ValidateOptions(values, 2, ["--query", "--timeout"], ["--exact"]);
|
||||
return 30;
|
||||
case "open":
|
||||
ValidateOptions(values, 2, ["--name", "--timeout"], []);
|
||||
return 30;
|
||||
default:
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown session command. Run WxAgent.Host help.");
|
||||
}
|
||||
}
|
||||
|
||||
if (values[0] != "db" || values.Length < 2)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help.");
|
||||
}
|
||||
|
||||
switch (values[1])
|
||||
{
|
||||
case "scan":
|
||||
ValidateOptions(values, 2, ["--data-root", "--key-file", "--timeout"], ["--save"]);
|
||||
return 120;
|
||||
case "status":
|
||||
ValidateOptions(values, 2, ["--key-file", "--timeout"], []);
|
||||
return 30;
|
||||
case "query":
|
||||
ValidateOptions(values, 2, ["--account", "--database", "--key-file", "--timeout"], []);
|
||||
return 30;
|
||||
default:
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown database command. Run WxAgent.Host help.");
|
||||
}
|
||||
}
|
||||
|
||||
static void ValidateOptions(string[] values, int startIndex, HashSet<string> valueOptions, HashSet<string> flagOptions)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (var index = startIndex; index < values.Length; index++)
|
||||
{
|
||||
var option = values[index];
|
||||
if (!seen.Add(option))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Duplicate option {option}.");
|
||||
}
|
||||
|
||||
if (flagOptions.Contains(option))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!valueOptions.Contains(option))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Unknown argument {option}.");
|
||||
}
|
||||
|
||||
if (++index >= values.Length || values[index].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing value for {option}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static string? GetOption(string[] values, string name)
|
||||
{
|
||||
var index = Array.IndexOf(values, name);
|
||||
return index >= 0 && index + 1 < values.Length && !values[index + 1].StartsWith("--", StringComparison.Ordinal)
|
||||
? values[index + 1]
|
||||
: null;
|
||||
}
|
||||
|
||||
static string GetRequiredOption(string[] values, string name) =>
|
||||
GetOption(values, name) ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing required option {name}.");
|
||||
|
||||
static bool HasOption(string[] values, string name) => Array.IndexOf(values, name) >= 0;
|
||||
|
||||
static int GetTimeoutSeconds(string[] values, int fallback)
|
||||
{
|
||||
var raw = GetOption(values, "--timeout");
|
||||
if (raw is null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (!int.TryParse(raw, out var parsed) || parsed <= 0)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "--timeout must be a positive whole number of seconds.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
static int GetPositiveIntOption(string[] values, string name, int fallback, int maximum, bool allowZero = false)
|
||||
{
|
||||
var raw = GetOption(values, name);
|
||||
if (raw is null)
|
||||
{
|
||||
return fallback;
|
||||
}
|
||||
|
||||
var minimum = allowZero ? 0 : 1;
|
||||
if (!int.TryParse(raw, out var parsed) || parsed < minimum || parsed > maximum)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} must be between {minimum} and {maximum}.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
static bool IsImagePath(string path) => new[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" }
|
||||
.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
static object ToEventOutput(MessageEvent messageEvent, bool includeContent) => new
|
||||
{
|
||||
messageEvent.EventId,
|
||||
messageEvent.Kind,
|
||||
messageEvent.Session,
|
||||
messageEvent.ObservedAt,
|
||||
messageEvent.Recovered,
|
||||
message = messageEvent.Message is null ? null : ToMessageOutput(messageEvent.Message, includeContent)
|
||||
};
|
||||
|
||||
static object ToMessageOutput(ChatMessageSnapshot message, bool includeContent) => new
|
||||
{
|
||||
message.Fingerprint,
|
||||
message.Type,
|
||||
length = message.Text.Length,
|
||||
content = includeContent ? message.Text : null,
|
||||
quote = includeContent ? message.Quote : null
|
||||
};
|
||||
|
||||
static int CountNodes(UiNodeSnapshot node) => 1 + node.Children.Sum(CountNodes);
|
||||
|
||||
static void PrintHelp() => Console.WriteLine("""
|
||||
WxAgent.Host commands:
|
||||
doctor [--timeout 30]
|
||||
inspect-ui --output <path> [--timeout 30]
|
||||
smoke [--output <path>] [--timeout 30]
|
||||
chat send --text <one-line-text> [--timeout 30]
|
||||
chat reply-latest --text <one-line-text> [--timeout 60]
|
||||
chat send-file --path <file> [--timeout 60]
|
||||
chat send-image --path <image> [--timeout 60]
|
||||
chat read [--limit 20] [--include-content] [--timeout 30]
|
||||
chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60]
|
||||
chat monitor [--seconds 60] [--state-file <path>] [--include-content] [--timeout <seconds>]
|
||||
chat listen [--seconds 30] [--include-content] [--timeout <seconds>]
|
||||
session list [--timeout 30]
|
||||
session search --query <text> [--exact] [--timeout 30]
|
||||
session current [--timeout 30]
|
||||
session open --name <session-name> [--timeout 30]
|
||||
db scan [--data-root <xwechat_files>] [--save] [--key-file <path>] [--timeout 120]
|
||||
db status [--key-file <path>] [--timeout 30]
|
||||
db query --account <fingerprint> --database <relative-path> [--key-file <path>] [--timeout 30]
|
||||
|
||||
Chat commands are restricted to File Transfer Assistant. Message content is omitted unless --include-content is explicit.
|
||||
Database keys are never printed. db scan only saves verified keys when --save is present.
|
||||
""");
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<AssemblyName>WxAgent.Host</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WxAgent.Windows\WxAgent.Windows.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,79 @@
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public static class DatabaseKeyStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions();
|
||||
|
||||
public static string DefaultPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "WxAgent", "database-keys.json");
|
||||
|
||||
public static async Task SaveAsync(IReadOnlyList<AccountKeySet> accounts, string? path, CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path ?? DefaultPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
await using (new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
}
|
||||
|
||||
RestrictToCurrentUser(temporary);
|
||||
await using (var stream = new FileStream(temporary, FileMode.Truncate, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, accounts, JsonOptions, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(temporary, fullPath, true);
|
||||
RestrictToCurrentUser(fullPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary))
|
||||
{
|
||||
File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<AccountKeySet>> LoadAsync(string? path, CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path ?? DefaultPath);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous);
|
||||
return await JsonSerializer.DeserializeAsync<List<AccountKeySet>>(stream, JsonOptions, cancellationToken) ?? [];
|
||||
}
|
||||
|
||||
private static JsonSerializerOptions CreateJsonOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
options.Converters.Add(new JsonStringEnumConverter());
|
||||
return options;
|
||||
}
|
||||
|
||||
internal static void RestrictToCurrentUser(string path)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var identity = WindowsIdentity.GetCurrent().User ?? throw new InvalidOperationException("Current Windows user SID is unavailable.");
|
||||
var security = new FileSecurity();
|
||||
security.SetOwner(identity);
|
||||
security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
|
||||
security.AddAccessRule(new FileSystemAccessRule(identity, FileSystemRights.FullControl, AccessControlType.Allow));
|
||||
new FileInfo(path).SetAccessControl(security);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Text.Json;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public static class ListenerCheckpointStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true };
|
||||
|
||||
public static string DefaultPath => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"WxAgent",
|
||||
"listener-checkpoint.json");
|
||||
|
||||
public static async Task<ListenerCheckpoint?> LoadAsync(string? path, CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path ?? DefaultPath);
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous);
|
||||
var checkpoint = await JsonSerializer.DeserializeAsync<ListenerCheckpoint>(stream, JsonOptions, cancellationToken)
|
||||
?? throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Listener checkpoint is empty or invalid.");
|
||||
return checkpoint.Version == 2 ? checkpoint : null;
|
||||
}
|
||||
|
||||
public static async Task SaveAsync(ListenerCheckpoint checkpoint, string? path, CancellationToken cancellationToken)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path ?? DefaultPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
await using (new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
}
|
||||
DatabaseKeyStore.RestrictToCurrentUser(temporary);
|
||||
await using (var stream = new FileStream(temporary, FileMode.Truncate, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, checkpoint, JsonOptions, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
File.Move(temporary, fullPath, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
OpenProcess
|
||||
CloseHandle
|
||||
VirtualQueryEx
|
||||
ReadProcessMemory
|
||||
QueryFullProcessImageName
|
||||
@@ -0,0 +1,158 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.InteropServices;
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.System.Memory;
|
||||
using Windows.Win32.System.Threading;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public sealed record ProcessAccessProbe(bool Success, string? Error);
|
||||
|
||||
public static unsafe class ProcessMemoryScanner
|
||||
{
|
||||
private const int ChunkSize = 1024 * 1024;
|
||||
private const ulong MaximumRegionSize = 500UL * 1024 * 1024;
|
||||
private const ulong MaximumUserAddress = 0x00007FFFFFFEFFFF;
|
||||
|
||||
public static ProcessAccessProbe Probe(int processId)
|
||||
{
|
||||
var process = PInvoke.OpenProcess(PROCESS_ACCESS_RIGHTS.PROCESS_QUERY_INFORMATION | PROCESS_ACCESS_RIGHTS.PROCESS_VM_READ, false, (uint)processId);
|
||||
if (process.IsNull)
|
||||
{
|
||||
return new ProcessAccessProbe(false, new Win32Exception(Marshal.GetLastWin32Error()).Message);
|
||||
}
|
||||
|
||||
PInvoke.CloseHandle(process);
|
||||
return new ProcessAccessProbe(true, null);
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WcdbKeyCandidate> Scan(int processId, CancellationToken cancellationToken)
|
||||
{
|
||||
var process = PInvoke.OpenProcess(PROCESS_ACCESS_RIGHTS.PROCESS_QUERY_INFORMATION | PROCESS_ACCESS_RIGHTS.PROCESS_VM_READ, false, (uint)processId);
|
||||
if (process.IsNull)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.ProcessAccessDenied, $"Cannot read Weixin process {processId}.", new Win32Exception(Marshal.GetLastWin32Error()));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Scan(process, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PInvoke.CloseHandle(process);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<WcdbKeyCandidate> Scan(HANDLE process, CancellationToken cancellationToken)
|
||||
{
|
||||
var scanner = new WcdbKeyCandidateScanner();
|
||||
var found = new List<WcdbKeyCandidate>();
|
||||
ulong? contiguousEnd = null;
|
||||
ulong address = 0;
|
||||
while (address < MaximumUserAddress)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
MEMORY_BASIC_INFORMATION region;
|
||||
if (PInvoke.VirtualQueryEx(process, (void*)address, ®ion, (nuint)sizeof(MEMORY_BASIC_INFORMATION)) == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var baseAddress = (ulong)region.BaseAddress;
|
||||
var regionSize = (ulong)region.RegionSize;
|
||||
if (regionSize == 0 || baseAddress > ulong.MaxValue - regionSize)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var next = baseAddress + regionSize;
|
||||
if (next <= address)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (!IsReadable(region) || regionSize > MaximumRegionSize)
|
||||
{
|
||||
BreakRun(scanner, found);
|
||||
contiguousEnd = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (contiguousEnd != baseAddress)
|
||||
{
|
||||
BreakRun(scanner, found);
|
||||
}
|
||||
|
||||
contiguousEnd = ReadRegion(process, baseAddress, regionSize, scanner, found, cancellationToken)
|
||||
? next
|
||||
: null;
|
||||
}
|
||||
|
||||
address = next;
|
||||
}
|
||||
|
||||
BreakRun(scanner, found);
|
||||
return found;
|
||||
}
|
||||
|
||||
private static bool ReadRegion(
|
||||
HANDLE process,
|
||||
ulong baseAddress,
|
||||
ulong regionSize,
|
||||
WcdbKeyCandidateScanner scanner,
|
||||
List<WcdbKeyCandidate> found,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var tailIsContiguous = false;
|
||||
var offset = 0UL;
|
||||
while (offset < regionSize)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var count = (int)Math.Min((ulong)ChunkSize, regionSize - offset);
|
||||
var buffer = GC.AllocateUninitializedArray<byte>(count);
|
||||
nuint bytesRead = 0;
|
||||
bool success;
|
||||
fixed (byte* destination = buffer)
|
||||
{
|
||||
success = PInvoke.ReadProcessMemory(process, (void*)(baseAddress + offset), destination, (nuint)count, &bytesRead);
|
||||
}
|
||||
|
||||
if (bytesRead > 0)
|
||||
{
|
||||
var length = checked((int)Math.Min(bytesRead, (nuint)count));
|
||||
found.AddRange(scanner.Feed(buffer.AsSpan(0, length)));
|
||||
}
|
||||
|
||||
if (!success || bytesRead != (nuint)count)
|
||||
{
|
||||
BreakRun(scanner, found);
|
||||
tailIsContiguous = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
tailIsContiguous = true;
|
||||
}
|
||||
|
||||
offset += (ulong)count;
|
||||
}
|
||||
|
||||
return tailIsContiguous;
|
||||
}
|
||||
|
||||
private static void BreakRun(WcdbKeyCandidateScanner scanner, List<WcdbKeyCandidate> found) =>
|
||||
found.AddRange(scanner.Feed([], finalBlock: true));
|
||||
|
||||
private static bool IsReadable(MEMORY_BASIC_INFORMATION region)
|
||||
{
|
||||
const uint memCommit = 0x1000;
|
||||
const uint pageNoAccess = 0x01;
|
||||
const uint pageGuard = 0x100;
|
||||
const uint readableMask = 0x02 | 0x04 | 0x08 | 0x20 | 0x40 | 0x80;
|
||||
var state = (uint)region.State;
|
||||
var protection = (uint)region.Protect;
|
||||
return state == memCommit && (protection & (pageNoAccess | pageGuard)) == 0 && (protection & readableMask) != 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public sealed record DatabaseMetadata(string CipherVersion, string SqliteVersion, long SchemaObjectCount, IReadOnlyList<string> SchemaObjects, bool WritesRejected);
|
||||
|
||||
public static class SqlCipherDatabaseReader
|
||||
{
|
||||
private static readonly Lazy<bool> Initialized = new(() =>
|
||||
{
|
||||
SQLitePCL.Batteries_V2.Init();
|
||||
return true;
|
||||
});
|
||||
|
||||
public static async Task<DatabaseMetadata> ReadMetadataAsync(string databasePath, string hexKey, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(databasePath);
|
||||
if (hexKey.Length != 64 || !hexKey.All(Uri.IsHexDigit))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database key must be exactly 64 hexadecimal characters.");
|
||||
}
|
||||
|
||||
_ = Initialized.Value;
|
||||
try
|
||||
{
|
||||
var connectionString = new SqliteConnectionStringBuilder
|
||||
{
|
||||
DataSource = Path.GetFullPath(databasePath),
|
||||
Mode = SqliteOpenMode.ReadOnly,
|
||||
Cache = SqliteCacheMode.Private,
|
||||
Pooling = false,
|
||||
DefaultTimeout = 10
|
||||
}.ToString();
|
||||
|
||||
await using var connection = new SqliteConnection(connectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = $"PRAGMA key = \"x'{hexKey}'\";";
|
||||
command.CommandTimeout = 10;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA cipher_compatibility = 4; PRAGMA query_only = ON;";
|
||||
command.CommandTimeout = 10;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var cipherVersion = await ReadCipherVersionAsync(connection, cancellationToken);
|
||||
var sqliteVersion = await ReadSqliteVersionAsync(connection, cancellationToken);
|
||||
var count = await ReadSchemaCountAsync(connection, cancellationToken);
|
||||
var names = new List<string>();
|
||||
await using (var command = connection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "SELECT name FROM sqlite_master WHERE name IS NOT NULL ORDER BY name LIMIT 100;";
|
||||
command.CommandTimeout = 10;
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var name = reader.GetString(0);
|
||||
names.Add(name.Length <= 256 ? name : name[..256]);
|
||||
}
|
||||
}
|
||||
|
||||
var writesRejected = false;
|
||||
try
|
||||
{
|
||||
var probeName = "__wxagent_read_only_probe_" + Guid.NewGuid().ToString("N");
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = $"CREATE TABLE {probeName}(value INTEGER);";
|
||||
command.CommandTimeout = 10;
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 8)
|
||||
{
|
||||
writesRejected = true;
|
||||
}
|
||||
|
||||
if (!writesRejected)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "Read-only SQLCipher connection unexpectedly accepted a write.");
|
||||
}
|
||||
|
||||
return new DatabaseMetadata(cipherVersion, sqliteVersion, count, names, writesRejected);
|
||||
}
|
||||
catch (SqliteException exception)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "SQLCipher could not open or read the selected database in read-only mode.", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> ReadCipherVersionAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "PRAGMA cipher_version;";
|
||||
command.CommandTimeout = 10;
|
||||
return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<string> ReadSqliteVersionAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT sqlite_version();";
|
||||
command.CommandTimeout = 10;
|
||||
return Convert.ToString(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static async Task<long> ReadSchemaCountAsync(SqliteConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT count(*) FROM sqlite_master;";
|
||||
command.CommandTimeout = 10;
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public sealed record AccountDatabaseFile(string FullPath, string RelativePath, byte[] FirstPage, string Salt);
|
||||
|
||||
public sealed record AccountDatabaseRoot(string AccountRootPath, string Fingerprint, IReadOnlyList<AccountDatabaseFile> Databases);
|
||||
|
||||
public static class WechatDatabaseDiscovery
|
||||
{
|
||||
public static IReadOnlyList<AccountDatabaseRoot> FindAccountRoots(
|
||||
string? xwechatFilesRoot = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var root = xwechatFilesRoot ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "xwechat_files");
|
||||
if (!Directory.Exists(root))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var accounts = new List<AccountDatabaseRoot>();
|
||||
foreach (var accountDirectory in Directory.EnumerateDirectories(root))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var dbStorage = Path.Combine(accountDirectory, "db_storage");
|
||||
if (!Directory.Exists(dbStorage))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var databases = new List<AccountDatabaseFile>();
|
||||
foreach (var path in Directory.EnumerateFiles(dbStorage, "*.db", SearchOption.AllDirectories))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var page = new byte[SqlCipherPageVerifier.PageSize];
|
||||
using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
|
||||
stream.ReadExactly(page);
|
||||
|
||||
var relativePath = Path.GetRelativePath(dbStorage, path).Replace('\\', '/');
|
||||
databases.Add(new AccountDatabaseFile(path, relativePath, page, Convert.ToHexString(page.AsSpan(0, SqlCipherPageVerifier.SaltSize)).ToLowerInvariant()));
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
accounts.Add(new AccountDatabaseRoot(dbStorage, AccountRootFingerprint.Create(dbStorage), databases));
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using System.Diagnostics;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public sealed record DatabaseScanResult(IReadOnlyList<AccountKeySet> Accounts, int ProcessCount, int CandidateCount, int DatabaseCount);
|
||||
|
||||
public static class WechatDatabaseScanner
|
||||
{
|
||||
public static DatabaseScanResult Scan(string? xwechatFilesRoot, CancellationToken cancellationToken)
|
||||
{
|
||||
var accounts = WechatDatabaseDiscovery.FindAccountRoots(xwechatFilesRoot, cancellationToken);
|
||||
if (accounts.Count == 0)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.DataRootNotFound, "No xwechat_files account db_storage directory was found.");
|
||||
}
|
||||
|
||||
var processes = Process.GetProcessesByName("Weixin").OrderByDescending(process => process.WorkingSet64).ToArray();
|
||||
if (processes.Length == 0)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.WechatNotRunning, "Weixin.exe is not running.");
|
||||
}
|
||||
|
||||
var evidence = accounts.ToDictionary(account => account.Fingerprint, _ => new Dictionary<string, DatabaseKeyEvidence>(StringComparer.OrdinalIgnoreCase));
|
||||
var candidateCount = 0;
|
||||
var scannedProcessCount = 0;
|
||||
string? version = null;
|
||||
foreach (var process in processes)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
IReadOnlyList<WcdbKeyCandidate> candidates;
|
||||
try
|
||||
{
|
||||
candidates = ProcessMemoryScanner.Scan(process.Id, cancellationToken);
|
||||
scannedProcessCount++;
|
||||
version ??= SafeVersion(process);
|
||||
}
|
||||
catch (WxAgentException exception) when (exception.Code == WxAgentErrorCode.ProcessAccessDenied)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
candidateCount += candidates.Count;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
foreach (var account in accounts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
foreach (var database in account.Databases)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (evidence[account.Fingerprint].ContainsKey(database.RelativePath) ||
|
||||
candidate.SaltHint is not null && !string.Equals(candidate.SaltHint, database.Salt, StringComparison.OrdinalIgnoreCase) ||
|
||||
!SqlCipherPageVerifier.VerifyHexKey(database.FirstPage, candidate.EncKey))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
evidence[account.Fingerprint][database.RelativePath] = new DatabaseKeyEvidence(
|
||||
database.RelativePath,
|
||||
database.Salt,
|
||||
candidate.EncKey,
|
||||
process.Id,
|
||||
DateTimeOffset.UtcNow,
|
||||
KeyBindingConfidence.PageHmacVerified);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scannedProcessCount == 0)
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.ProcessAccessDenied, "No Weixin process could be opened for read-only memory access.");
|
||||
}
|
||||
|
||||
var results = accounts.Select(account => new AccountKeySet(
|
||||
account.Fingerprint,
|
||||
account.AccountRootPath,
|
||||
version,
|
||||
DateTimeOffset.UtcNow,
|
||||
evidence[account.Fingerprint].Values.OrderBy(item => item.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray())).ToArray();
|
||||
|
||||
return new DatabaseScanResult(results, processes.Length, candidateCount, accounts.Sum(account => account.Databases.Count));
|
||||
}
|
||||
|
||||
private static string? SafeVersion(Process process)
|
||||
{
|
||||
try { return process.MainModule?.FileVersionInfo.FileVersion; } catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System.Diagnostics;
|
||||
using FlaUI.Core;
|
||||
using FlaUI.Core.Definitions;
|
||||
using FlaUI.UIA3;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public sealed record ProcessDiagnostic(int Id, long WorkingSetBytes, int SessionId, string? Version, bool CanReadMemory, string? AccessError);
|
||||
|
||||
public sealed record DoctorReport(
|
||||
string OperatingSystem,
|
||||
bool UserInteractive,
|
||||
int CurrentSessionId,
|
||||
IReadOnlyList<ProcessDiagnostic> Processes,
|
||||
bool WindowFound,
|
||||
IReadOnlyDictionary<string, bool> RequiredControls,
|
||||
int DataRootCount,
|
||||
IReadOnlyList<WxAgentErrorCode> Errors);
|
||||
|
||||
public static class WechatDoctor
|
||||
{
|
||||
public static DoctorReport Run(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var errors = new List<WxAgentErrorCode>();
|
||||
var processes = Process.GetProcessesByName("Weixin")
|
||||
.OrderByDescending(process => process.WorkingSet64)
|
||||
.Select(process => CreateProcessDiagnostic(process, cancellationToken))
|
||||
.ToArray();
|
||||
|
||||
if (processes.Length == 0)
|
||||
{
|
||||
errors.Add(WxAgentErrorCode.WechatNotRunning);
|
||||
}
|
||||
else if (processes.All(process => !process.CanReadMemory))
|
||||
{
|
||||
errors.Add(WxAgentErrorCode.ProcessAccessDenied);
|
||||
}
|
||||
|
||||
var controls = WechatLocators.RequiredAutomationIds.ToDictionary(id => id, _ => false, StringComparer.Ordinal);
|
||||
var windowFound = false;
|
||||
try
|
||||
{
|
||||
using var automation = new UIA3Automation();
|
||||
var window = FindWechatWindow(automation);
|
||||
windowFound = window is not null;
|
||||
if (window is not null)
|
||||
{
|
||||
foreach (var id in controls.Keys.ToArray())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
controls[id] = window.FindFirstDescendant(condition => condition.ByAutomationId(id)) is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
windowFound = false;
|
||||
}
|
||||
|
||||
if (!windowFound && processes.Length > 0)
|
||||
{
|
||||
errors.Add(processes.Any(process => process.SessionId != Process.GetCurrentProcess().SessionId)
|
||||
? WxAgentErrorCode.SessionLocked
|
||||
: WxAgentErrorCode.WindowNotFound);
|
||||
}
|
||||
else if (windowFound && WechatLocators.ClassifyFoundWindow(controls) is { } uiError)
|
||||
{
|
||||
errors.Add(uiError);
|
||||
}
|
||||
|
||||
var dataRootCount = WechatDatabaseDiscovery.FindAccountRoots(cancellationToken: cancellationToken).Count;
|
||||
if (dataRootCount == 0)
|
||||
{
|
||||
errors.Add(WxAgentErrorCode.DataRootNotFound);
|
||||
}
|
||||
|
||||
return new DoctorReport(
|
||||
Environment.OSVersion.VersionString,
|
||||
Environment.UserInteractive,
|
||||
Process.GetCurrentProcess().SessionId,
|
||||
processes,
|
||||
windowFound,
|
||||
controls,
|
||||
dataRootCount,
|
||||
errors.Distinct().ToArray());
|
||||
}
|
||||
|
||||
internal static FlaUI.Core.AutomationElements.AutomationElement? FindWechatWindow(AutomationBase automation) =>
|
||||
automation.GetDesktop().FindAllChildren(condition => condition.ByControlType(ControlType.Window))
|
||||
.FirstOrDefault(element => string.Equals(element.Name, "微信", StringComparison.Ordinal));
|
||||
|
||||
private static ProcessDiagnostic CreateProcessDiagnostic(Process process, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string? version = null;
|
||||
try
|
||||
{
|
||||
version = process.MainModule?.FileVersionInfo.FileVersion;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
var access = ProcessMemoryScanner.Probe(process.Id);
|
||||
return new ProcessDiagnostic(process.Id, process.WorkingSet64, process.SessionId, version, access.Success, access.Error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Text.Json;
|
||||
using FlaUI.Core.AutomationElements;
|
||||
using FlaUI.UIA3;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Windows;
|
||||
|
||||
public static class WechatUiInspector
|
||||
{
|
||||
public static async Task<UiNodeSnapshot> CaptureAsync(string outputPath, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputPath);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
using var automation = new UIA3Automation();
|
||||
var window = WechatDoctor.FindWechatWindow(automation)
|
||||
?? throw new WxAgentException(WxAgentErrorCode.WindowNotFound, "WeChat window was not found in the current interactive session.");
|
||||
|
||||
var snapshot = Capture(window, cancellationToken);
|
||||
var fullPath = Path.GetFullPath(outputPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
|
||||
await using var stream = new FileStream(fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous);
|
||||
await JsonSerializer.SerializeAsync(stream, snapshot, new JsonSerializerOptions { WriteIndented = true }, cancellationToken);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static UiNodeSnapshot Capture(AutomationElement element, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var children = new List<UiNodeSnapshot>();
|
||||
AutomationElement[] rawChildren;
|
||||
try
|
||||
{
|
||||
rawChildren = element.FindAllChildren();
|
||||
}
|
||||
catch
|
||||
{
|
||||
rawChildren = [];
|
||||
}
|
||||
|
||||
foreach (var child in rawChildren)
|
||||
{
|
||||
children.Add(Capture(child, cancellationToken));
|
||||
}
|
||||
|
||||
string bounds;
|
||||
try
|
||||
{
|
||||
var rectangle = element.BoundingRectangle;
|
||||
bounds = $"{rectangle.Left:0},{rectangle.Top:0},{rectangle.Width:0},{rectangle.Height:0}";
|
||||
}
|
||||
catch
|
||||
{
|
||||
bounds = string.Empty;
|
||||
}
|
||||
|
||||
return new UiNodeSnapshot(
|
||||
element.ControlType.ToString(),
|
||||
UiSnapshotSanitizer.SanitizeName(SafeRead(() => element.Name)),
|
||||
UiSnapshotSanitizer.SanitizeAutomationId(SafeRead(() => element.AutomationId)),
|
||||
SafeRead(() => element.IsEnabled, false),
|
||||
bounds,
|
||||
children);
|
||||
}
|
||||
|
||||
private static string SafeRead(Func<string> read)
|
||||
{
|
||||
try { return read() ?? string.Empty; } catch { return string.Empty; }
|
||||
}
|
||||
|
||||
private static T SafeRead<T>(Func<T> read, T fallback)
|
||||
{
|
||||
try { return read(); } catch { return fallback; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<PlatformTarget>x64</PlatformTarget>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\WxAgent.Core\WxAgent.Core.csproj" />
|
||||
<PackageReference Include="FlaUI.UIA3" Version="5.0.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite.Core" Version="8.0.30" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32" Version="0.3.321" PrivateAssets="all" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user