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