Initial import
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum ChatMessageType
|
||||
{
|
||||
Text,
|
||||
Image,
|
||||
File,
|
||||
Video,
|
||||
Voice,
|
||||
Link,
|
||||
Quote,
|
||||
System
|
||||
}
|
||||
|
||||
public sealed record QuotedMessageSnapshot(string? Sender, string Text);
|
||||
|
||||
public sealed record ChatMessageSource(string Text, string? SourceId = null);
|
||||
|
||||
public sealed record ChatMessageSnapshot(
|
||||
string Text,
|
||||
string Fingerprint,
|
||||
int VisibleIndex,
|
||||
ChatMessageType Type,
|
||||
QuotedMessageSnapshot? Quote = null);
|
||||
|
||||
public static class VisibleMessageParser
|
||||
{
|
||||
public static IReadOnlyList<ChatMessageSnapshot> Parse(IEnumerable<string> accessibleNames) =>
|
||||
Parse(accessibleNames.Select(text => new ChatMessageSource(text)));
|
||||
|
||||
public static IReadOnlyList<ChatMessageSnapshot> Parse(IEnumerable<ChatMessageSource> sources)
|
||||
{
|
||||
var occurrences = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
var messages = new List<ChatMessageSnapshot>();
|
||||
foreach (var source in sources.Where(value => !string.IsNullOrWhiteSpace(value.Text)))
|
||||
{
|
||||
var text = source.Text;
|
||||
var occurrence = occurrences.TryGetValue(text, out var count) ? count : 0;
|
||||
occurrences[text] = occurrence + 1;
|
||||
var identity = $"{occurrence}:{text}";
|
||||
var fingerprint = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity))).ToLowerInvariant();
|
||||
var (replyText, quote) = ParseQuote(text);
|
||||
var type = quote is null ? Classify(replyText) : ChatMessageType.Quote;
|
||||
messages.Add(new ChatMessageSnapshot(replyText, fingerprint, messages.Count, type, quote));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
public static (string Text, QuotedMessageSnapshot? Quote) ParseQuote(string accessibleName)
|
||||
{
|
||||
var lines = accessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (lines.Length >= 2 && lines[^1].StartsWith("引用 ", StringComparison.Ordinal))
|
||||
{
|
||||
var descriptor = lines[^1];
|
||||
var messageMarker = descriptor.IndexOf(" 的消息", StringComparison.Ordinal);
|
||||
var separator = descriptor.IndexOfAny([':', ':']);
|
||||
if (messageMarker > 3 && separator > messageMarker)
|
||||
{
|
||||
var sender = descriptor[3..messageMarker].Trim();
|
||||
var quoteText = descriptor[(separator + 1)..].Trim();
|
||||
return (string.Join('\n', lines[..^1]), new QuotedMessageSnapshot(sender, quoteText));
|
||||
}
|
||||
}
|
||||
|
||||
var quoteIndex = Array.FindIndex(lines, line =>
|
||||
string.Equals(line, "引用", StringComparison.Ordinal) || line.StartsWith("引用:", StringComparison.Ordinal));
|
||||
if (quoteIndex < 0 || lines.Length - quoteIndex < 3)
|
||||
{
|
||||
return (accessibleName, null);
|
||||
}
|
||||
|
||||
var marker = lines[quoteIndex];
|
||||
var legacySender = marker.Length > 3 ? marker[3..].Trim() : null;
|
||||
var legacyQuoteText = string.Join('\n', lines[(quoteIndex + 1)..^1]);
|
||||
return (lines[^1], new QuotedMessageSnapshot(string.IsNullOrWhiteSpace(legacySender) ? null : legacySender, legacyQuoteText));
|
||||
}
|
||||
|
||||
public static ChatMessageType Classify(string text)
|
||||
{
|
||||
if (text is "[图片]" or "图片" || text.StartsWith("图片\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.Image;
|
||||
}
|
||||
|
||||
if (text is "[视频]" or "视频" || text.StartsWith("视频\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.Video;
|
||||
}
|
||||
|
||||
if (text is "[语音]" or "语音" || text.StartsWith("语音\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.Voice;
|
||||
}
|
||||
|
||||
if (Uri.TryCreate(text, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https")
|
||||
{
|
||||
return ChatMessageType.Link;
|
||||
}
|
||||
|
||||
if (text.StartsWith("文件\n", StringComparison.Ordinal))
|
||||
{
|
||||
return ChatMessageType.File;
|
||||
}
|
||||
|
||||
if (!text.Contains(Path.DirectorySeparatorChar) && !text.Contains(Path.AltDirectorySeparatorChar) &&
|
||||
Path.GetExtension(text) is { Length: > 1 } extension && extension.Length <= 10)
|
||||
{
|
||||
return ChatMessageType.File;
|
||||
}
|
||||
|
||||
return text.StartsWith("[", StringComparison.Ordinal) && text.EndsWith("]", StringComparison.Ordinal)
|
||||
? ChatMessageType.System
|
||||
: ChatMessageType.Text;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BoundedMessageDeduper(int capacity = 2048)
|
||||
{
|
||||
private readonly Queue<string> _order = new();
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
|
||||
public bool TryAdd(string fingerprint)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
|
||||
if (!_seen.Add(fingerprint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_order.Enqueue(fingerprint);
|
||||
if (_order.Count > capacity)
|
||||
{
|
||||
_seen.Remove(_order.Dequeue());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum KeyBindingConfidence
|
||||
{
|
||||
PageHmacVerified
|
||||
}
|
||||
|
||||
public sealed record DatabaseKeyEvidence(
|
||||
string RelativePath,
|
||||
string Salt,
|
||||
string EncKey,
|
||||
int SourceProcessId,
|
||||
DateTimeOffset VerifiedAt,
|
||||
KeyBindingConfidence Confidence);
|
||||
|
||||
public sealed record AccountKeySet(
|
||||
string AccountRootFingerprint,
|
||||
string AccountRootPath,
|
||||
string? WechatVersion,
|
||||
DateTimeOffset LastSeenAt,
|
||||
IReadOnlyList<DatabaseKeyEvidence> Databases);
|
||||
|
||||
public static class AccountKeySetMerge
|
||||
{
|
||||
public static IReadOnlyList<AccountKeySet> Merge(
|
||||
IReadOnlyList<AccountKeySet> existing,
|
||||
IReadOnlyList<AccountKeySet> incoming)
|
||||
{
|
||||
var accounts = existing.ToDictionary(account => account.AccountRootFingerprint, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var current in incoming)
|
||||
{
|
||||
if (!accounts.TryGetValue(current.AccountRootFingerprint, out var previous))
|
||||
{
|
||||
accounts[current.AccountRootFingerprint] = current;
|
||||
continue;
|
||||
}
|
||||
|
||||
var databases = previous.Databases.ToDictionary(database => database.RelativePath, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var database in current.Databases)
|
||||
{
|
||||
databases[database.RelativePath] = database;
|
||||
}
|
||||
|
||||
accounts[current.AccountRootFingerprint] = current with
|
||||
{
|
||||
WechatVersion = current.WechatVersion ?? previous.WechatVersion,
|
||||
Databases = databases.Values.OrderBy(database => database.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
return accounts.Values.OrderBy(account => account.AccountRootFingerprint, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static class AccountRootFingerprint
|
||||
{
|
||||
public static string Create(string accountRootPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(accountRootPath);
|
||||
var normalized = accountRootPath.Replace('/', '\\').TrimEnd('\\').ToUpperInvariant();
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum MessageEventKind
|
||||
{
|
||||
MessageReceived,
|
||||
Reconnected
|
||||
}
|
||||
|
||||
public sealed record MessageEvent(
|
||||
string EventId,
|
||||
MessageEventKind Kind,
|
||||
string Session,
|
||||
ChatMessageSnapshot? Message,
|
||||
DateTimeOffset ObservedAt,
|
||||
bool Recovered);
|
||||
|
||||
public sealed record ListenerCheckpoint(
|
||||
int Version,
|
||||
string Session,
|
||||
DateTimeOffset UpdatedAt,
|
||||
IReadOnlyList<string> Fingerprints);
|
||||
|
||||
public sealed class MessageEventState
|
||||
{
|
||||
private readonly int _capacity;
|
||||
private readonly Queue<string> _order = new();
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
|
||||
public MessageEventState(int capacity = 4096, ListenerCheckpoint? checkpoint = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
|
||||
_capacity = capacity;
|
||||
if (checkpoint is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var fingerprint in checkpoint.Fingerprints.TakeLast(capacity))
|
||||
{
|
||||
Add(fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
public MessageEvent? TryCreateMessage(
|
||||
string session,
|
||||
ChatMessageSnapshot message,
|
||||
DateTimeOffset observedAt,
|
||||
bool recovered)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(session);
|
||||
if (!Add(message.Fingerprint))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new MessageEvent(
|
||||
message.Fingerprint,
|
||||
MessageEventKind.MessageReceived,
|
||||
session,
|
||||
message,
|
||||
observedAt,
|
||||
recovered);
|
||||
}
|
||||
|
||||
public ListenerCheckpoint CreateCheckpoint(string session, DateTimeOffset updatedAt) =>
|
||||
new(2, session, updatedAt, _order.ToArray());
|
||||
|
||||
private bool Add(string fingerprint)
|
||||
{
|
||||
if (!_seen.Add(fingerprint))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_order.Enqueue(fingerprint);
|
||||
if (_order.Count > _capacity)
|
||||
{
|
||||
_seen.Remove(_order.Dequeue());
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MessageCallbackFailure(int CallbackIndex, string ErrorType);
|
||||
|
||||
public static class MessageCallbackDispatcher
|
||||
{
|
||||
public static async Task<IReadOnlyList<MessageCallbackFailure>> DispatchAsync(
|
||||
IReadOnlyList<Func<MessageEvent, CancellationToken, Task>> callbacks,
|
||||
MessageEvent messageEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var failures = new List<MessageCallbackFailure>();
|
||||
for (var index = 0; index < callbacks.Count; index++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await callbacks[index](messageEvent, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
failures.Add(new MessageCallbackFailure(index, exception.GetType().Name));
|
||||
}
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public static class SqlCipherPageVerifier
|
||||
{
|
||||
public const int PageSize = 4096;
|
||||
public const int SaltSize = 16;
|
||||
public const int KeySize = 32;
|
||||
public const int HmacSize = 64;
|
||||
|
||||
public static bool Verify(ReadOnlySpan<byte> page, ReadOnlySpan<byte> key, uint pageNumber = 1)
|
||||
{
|
||||
if (page.Length != PageSize || key.Length != KeySize || pageNumber == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Span<byte> hmacSalt = stackalloc byte[SaltSize];
|
||||
for (var index = 0; index < SaltSize; index++)
|
||||
{
|
||||
hmacSalt[index] = (byte)(page[index] ^ 0x3a);
|
||||
}
|
||||
|
||||
Span<byte> hmacKey = stackalloc byte[KeySize];
|
||||
Rfc2898DeriveBytes.Pbkdf2(key, hmacSalt, hmacKey, 2, HashAlgorithmName.SHA512);
|
||||
|
||||
var authenticatedLength = PageSize - SaltSize - HmacSize;
|
||||
var authenticated = new byte[authenticatedLength + sizeof(uint)];
|
||||
page.Slice(SaltSize, authenticatedLength).CopyTo(authenticated);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(authenticated.AsSpan(authenticatedLength), pageNumber);
|
||||
|
||||
var computed = HMACSHA512.HashData(hmacKey, authenticated);
|
||||
var valid = CryptographicOperations.FixedTimeEquals(computed, page[^HmacSize..]);
|
||||
CryptographicOperations.ZeroMemory(hmacKey);
|
||||
CryptographicOperations.ZeroMemory(authenticated);
|
||||
CryptographicOperations.ZeroMemory(computed);
|
||||
return valid;
|
||||
}
|
||||
|
||||
public static bool VerifyHexKey(ReadOnlySpan<byte> page, string hexKey, uint pageNumber = 1)
|
||||
{
|
||||
if (hexKey.Length != KeySize * 2)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
key = Convert.FromHexString(hexKey);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Verify(page, key, pageNumber);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record UiNodeSnapshot(
|
||||
string ControlType,
|
||||
string Name,
|
||||
string AutomationId,
|
||||
bool IsEnabled,
|
||||
string Bounds,
|
||||
IReadOnlyList<UiNodeSnapshot> Children);
|
||||
|
||||
public static class UiSnapshotSanitizer
|
||||
{
|
||||
private static readonly HashSet<string> SafeAutomationIds = new(StringComparer.Ordinal)
|
||||
{
|
||||
"MainView",
|
||||
"MainView.main_tabbar",
|
||||
"MainView.main_tabbar.tabbar_setting",
|
||||
"session_list",
|
||||
"chat_message_page",
|
||||
"chat_message_list",
|
||||
"chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view",
|
||||
"chat_input_field",
|
||||
"chat_input_field.qt_scrollarea_viewport",
|
||||
"tool_bar_accessible"
|
||||
};
|
||||
|
||||
public static string SanitizeName(string? value) => Sanitize(value, [], "text");
|
||||
|
||||
public static string SanitizeAutomationId(string? value) => Sanitize(value, SafeAutomationIds, "id");
|
||||
|
||||
private static string Sanitize(string? value, HashSet<string> allowList, string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || allowList.Contains(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value));
|
||||
return $"[{label}:{value.Length}:{Convert.ToHexString(bytes.AsSpan(0, 4)).ToLowerInvariant()}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.Text;
|
||||
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record WcdbKeyCandidate(string EncKey, string? SaltHint);
|
||||
|
||||
public sealed class WcdbKeyCandidateScanner
|
||||
{
|
||||
private const int KeyHexLength = 64;
|
||||
private const int SaltHexLength = 32;
|
||||
|
||||
private readonly StringBuilder _keyPrefix = new(KeyHexLength);
|
||||
private readonly char[] _saltSuffix = new char[SaltHexLength];
|
||||
private readonly HashSet<string> _seen = new(StringComparer.Ordinal);
|
||||
private long _runLength;
|
||||
|
||||
public IReadOnlyList<WcdbKeyCandidate> Feed(ReadOnlySpan<byte> bytes, bool finalBlock = false)
|
||||
{
|
||||
var found = new List<WcdbKeyCandidate>();
|
||||
foreach (var value in bytes)
|
||||
{
|
||||
if (IsAsciiHex(value))
|
||||
{
|
||||
AppendHex(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
CompleteRun(found);
|
||||
}
|
||||
}
|
||||
|
||||
if (finalBlock)
|
||||
{
|
||||
CompleteRun(found);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private void AppendHex(byte value)
|
||||
{
|
||||
var character = char.ToLowerInvariant((char)value);
|
||||
if (_runLength < KeyHexLength)
|
||||
{
|
||||
_keyPrefix.Append(character);
|
||||
}
|
||||
|
||||
_saltSuffix[_runLength % SaltHexLength] = character;
|
||||
_runLength++;
|
||||
}
|
||||
|
||||
private void CompleteRun(List<WcdbKeyCandidate> found)
|
||||
{
|
||||
if (_runLength >= KeyHexLength)
|
||||
{
|
||||
var key = _keyPrefix.ToString();
|
||||
var salt = _runLength >= KeyHexLength + SaltHexLength ? GetSaltSuffix() : null;
|
||||
if (_seen.Add(key + ":" + salt))
|
||||
{
|
||||
found.Add(new WcdbKeyCandidate(key, salt));
|
||||
}
|
||||
}
|
||||
|
||||
_keyPrefix.Clear();
|
||||
_runLength = 0;
|
||||
}
|
||||
|
||||
private string GetSaltSuffix()
|
||||
{
|
||||
var salt = new char[SaltHexLength];
|
||||
var start = (int)(_runLength % SaltHexLength);
|
||||
for (var index = 0; index < salt.Length; index++)
|
||||
{
|
||||
salt[index] = _saltSuffix[(start + index) % SaltHexLength];
|
||||
}
|
||||
|
||||
return new string(salt);
|
||||
}
|
||||
|
||||
private static bool IsAsciiHex(byte value) =>
|
||||
value is >= (byte)'0' and <= (byte)'9' or
|
||||
>= (byte)'a' and <= (byte)'f' or
|
||||
>= (byte)'A' and <= (byte)'F';
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public static class WechatLocators
|
||||
{
|
||||
public const string MainView = "MainView";
|
||||
public const string SessionList = "session_list";
|
||||
public const string ChatPage = "chat_message_page";
|
||||
public const string MessageList = "chat_message_list";
|
||||
public const string ChatInput = "chat_input_field";
|
||||
public const string ToolBar = "tool_bar_accessible";
|
||||
public const string CurrentChatName = "content_view.top_content_view.title_h_view.left_v_view.left_content_v_view.left_ui_.big_title_line_h_view.current_chat_name_label";
|
||||
public const string ChatBubbleItem = "chat_message_list.qt_scrollarea_viewport.chat_bubble_item_view";
|
||||
public const string FileTransferAssistant = "文件传输助手";
|
||||
public const string Search = "搜索";
|
||||
public const string Send = "发送";
|
||||
public const string FileTransferAssistantSearchResult = "search_item_function_文件传输助手";
|
||||
|
||||
public static IReadOnlyList<string> RequiredAutomationIds { get; } =
|
||||
[MainView, SessionList, ChatPage, MessageList, ChatInput, ToolBar];
|
||||
|
||||
public static WxAgentErrorCode? ClassifyFoundWindow(IReadOnlyDictionary<string, bool> controls)
|
||||
{
|
||||
if (controls.Values.All(found => !found))
|
||||
{
|
||||
return WxAgentErrorCode.WechatNotLoggedIn;
|
||||
}
|
||||
|
||||
if (!controls.GetValueOrDefault(MainView) || !controls.GetValueOrDefault(SessionList))
|
||||
{
|
||||
return WxAgentErrorCode.UiStructureChanged;
|
||||
}
|
||||
|
||||
var chatControls = new[] { ChatPage, MessageList, ChatInput, ToolBar };
|
||||
var visibleChatControls = chatControls.Count(id => controls.GetValueOrDefault(id));
|
||||
return visibleChatControls is 0 or 4 ? null : WxAgentErrorCode.UiStructureChanged;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public sealed record WechatSessionSnapshot(string Name, string AutomationId, bool IsCurrent);
|
||||
|
||||
public sealed record WechatSessionSearchResult(string Name, string AutomationId, string? Category, bool IsExactMatch);
|
||||
|
||||
public static class WechatSessionParser
|
||||
{
|
||||
public static IReadOnlyList<WechatSessionSnapshot> Parse(
|
||||
IEnumerable<(string AutomationId, string Name)> elements,
|
||||
string? currentName)
|
||||
{
|
||||
return elements
|
||||
.Where(element => element.AutomationId.StartsWith("session_item_", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(element.Name))
|
||||
.DistinctBy(element => element.AutomationId, StringComparer.Ordinal)
|
||||
.Select(element => new WechatSessionSnapshot(
|
||||
element.Name,
|
||||
element.AutomationId,
|
||||
string.Equals(element.Name, currentName, StringComparison.Ordinal)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<WechatSessionSearchResult> ParseSearchResults(
|
||||
IEnumerable<(string AutomationId, string AccessibleName)> elements,
|
||||
string query)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(query);
|
||||
return elements
|
||||
.Where(element => element.AutomationId.StartsWith("search_item_", StringComparison.Ordinal) &&
|
||||
!string.IsNullOrWhiteSpace(element.AccessibleName))
|
||||
.DistinctBy(element => element.AutomationId, StringComparer.Ordinal)
|
||||
.Select(element =>
|
||||
{
|
||||
var lines = element.AccessibleName.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
var exact = lines.FirstOrDefault(line => string.Equals(line, query, StringComparison.Ordinal));
|
||||
var name = exact ?? lines.Last();
|
||||
var category = lines.Length > 1 && !string.Equals(lines[0], name, StringComparison.Ordinal) ? lines[0] : null;
|
||||
return new WechatSessionSearchResult(name, element.AutomationId, category, exact is not null);
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace WxAgent.Core;
|
||||
|
||||
public enum WxAgentErrorCode
|
||||
{
|
||||
WechatNotRunning,
|
||||
WechatNotLoggedIn,
|
||||
WindowNotFound,
|
||||
ControlNotFound,
|
||||
UiStructureChanged,
|
||||
Timeout,
|
||||
PermissionMismatch,
|
||||
SessionLocked,
|
||||
UnsupportedWechatVersion,
|
||||
ResultUnconfirmed,
|
||||
OperationCancelled,
|
||||
InvalidOperationState,
|
||||
DataRootNotFound,
|
||||
ProcessAccessDenied,
|
||||
DatabaseKeyNotFound,
|
||||
DatabaseOpenFailed,
|
||||
ClipboardUnavailable,
|
||||
InvalidArgument
|
||||
}
|
||||
|
||||
public sealed class WxAgentException(WxAgentErrorCode code, string message, Exception? innerException = null)
|
||||
: Exception(message, innerException)
|
||||
{
|
||||
public WxAgentErrorCode Code { get; } = code;
|
||||
}
|
||||
Reference in New Issue
Block a user