Initial import

This commit is contained in:
2026-09-04 21:46:38 +08:00
commit 5e3aa97644
36 changed files with 4608 additions and 0 deletions
+142
View File
@@ -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;
}
}
+66
View File
@@ -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();
}
}
+113
View File
@@ -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;
}
}
+68
View File
@@ -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);
}
}
}
+44
View File
@@ -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';
}
+37
View File
@@ -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;
}
}
+43
View File
@@ -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();
}
}
+7
View File
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+29
View File
@@ -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;
}
+469
View File
@@ -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.
""");
+19
View File
@@ -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>
+79
View File
@@ -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);
}
}
}
+5
View File
@@ -0,0 +1,5 @@
OpenProcess
CloseHandle
VirtualQueryEx
ReadProcessMemory
QueryFullProcessImageName
+158
View File
@@ -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, &region, (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);
}
}
+889
View File
@@ -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; }
}
}
+113
View File
@@ -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);
}
}
+75
View File
@@ -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>