139 lines
8.4 KiB
C#
139 lines
8.4 KiB
C#
using WxAgent.Core;
|
|
|
|
namespace WxAgent.Windows;
|
|
|
|
public static class WechatContactDbReader
|
|
{
|
|
public static async Task<AccountKeySet> LoadAccountAsync(string? accountId = null, string? keyFile = null, CancellationToken cancellationToken = default)
|
|
{
|
|
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken).ConfigureAwait(false);
|
|
var matches = accounts.Where(account => accountId is null ||
|
|
string.Equals(account.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
if (matches.Length == 0)
|
|
throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "No matching account was found in the key store; run db scan first.");
|
|
if (matches.Length != 1)
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Select one account fingerprint explicitly; multiple cached accounts match.");
|
|
return matches[0];
|
|
}
|
|
|
|
public static async Task<DatabaseAccountIdentity?> ReadAccountIdentityAsync(AccountKeySet account,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
var storageDirectory = new DirectoryInfo(account.AccountRootPath);
|
|
var directoryName = storageDirectory.Name.Equals("db_storage", StringComparison.OrdinalIgnoreCase)
|
|
? storageDirectory.Parent?.Name ?? storageDirectory.Name
|
|
: storageDirectory.Name;
|
|
var separator = directoryName.LastIndexOf('_');
|
|
var candidates = new[]
|
|
{
|
|
directoryName,
|
|
separator > 0 ? directoryName[..separator] : directoryName
|
|
}.Where(candidate => !string.IsNullOrWhiteSpace(candidate)).Distinct(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var candidate in candidates)
|
|
{
|
|
var page = await ReadPageAsync(account, 10000, 0, candidate, false, cancellationToken).ConfigureAwait(false);
|
|
var exact = page.Contacts.Where(contact => string.Equals(contact.Username, candidate, StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
if (exact.Length == 1)
|
|
{
|
|
var contact = exact[0];
|
|
return new DatabaseAccountIdentity(account.AccountRootFingerprint, contact.Username, contact.DisplayName);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public static async Task<WechatContactPage> ReadPageAsync(AccountKeySet account, int limit = 200, int offset = 0,
|
|
string? contains = null, bool? groupsOnly = null, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var parameters = WechatContactQuery.Parameters(limit, offset, contains, groupsOnly);
|
|
var database = RequireContactDatabase(account);
|
|
// The shared reader revalidates page-1 HMAC on every open and enforces read-only mode.
|
|
var rows = await SqlCipherDatabaseReader.QueryRowsAsync(ContactDatabasePath(account), database.EncKey,
|
|
WechatContactQuery.Sql, parameters, cancellationToken).ConfigureAwait(false);
|
|
var contacts = rows.Select(row => new WechatDatabaseContact(
|
|
row.GetValueOrDefault("username") as string ?? throw new WxAgentException(
|
|
WxAgentErrorCode.DatabaseOpenFailed, "A contact row has no stable username."),
|
|
row.GetValueOrDefault("nick_name") as string, row.GetValueOrDefault("remark") as string,
|
|
row.GetValueOrDefault("small_head_url") as string)).ToArray();
|
|
return WechatContactQuery.Page(contacts, limit, offset);
|
|
}
|
|
|
|
public static async Task<IReadOnlyDictionary<string, string>> ReadDisplayNamesAsync(
|
|
AccountKeySet account, IReadOnlyList<string> usernames, CancellationToken cancellationToken = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(account);
|
|
ArgumentNullException.ThrowIfNull(usernames);
|
|
var distinct = usernames.Where(username => !string.IsNullOrWhiteSpace(username))
|
|
.Distinct(StringComparer.Ordinal).ToArray();
|
|
if (distinct.Length == 0) return new Dictionary<string, string>(StringComparer.Ordinal);
|
|
|
|
var database = RequireContactDatabase(account);
|
|
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
var path = ContactDatabasePath(account);
|
|
foreach (var chunk in distinct.Chunk(400))
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
var placeholders = string.Join(", ", chunk.Select((_, index) => $"$p{index}"));
|
|
var parameters = chunk.Select((username, index) =>
|
|
new KeyValuePair<string, object?>($"$p{index}", username)).ToArray();
|
|
var rows = await SqlCipherDatabaseReader.QueryRowsAsync(path, database.EncKey,
|
|
$"SELECT username, nick_name, remark FROM contact WHERE username IN ({placeholders});",
|
|
parameters, cancellationToken).ConfigureAwait(false);
|
|
foreach (var row in rows)
|
|
{
|
|
var username = row.GetValueOrDefault("username") as string;
|
|
if (string.IsNullOrWhiteSpace(username)) continue;
|
|
var remark = row.GetValueOrDefault("remark") as string;
|
|
var nickname = row.GetValueOrDefault("nick_name") as string;
|
|
var displayName = !string.IsNullOrWhiteSpace(remark) ? remark : nickname;
|
|
if (!string.IsNullOrWhiteSpace(displayName)) result[username] = displayName;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public static async Task<WechatGroupMemberPage> ReadGroupMembersPageAsync(AccountKeySet account, string group,
|
|
int limit = 500, int offset = 0, CancellationToken cancellationToken = default)
|
|
{
|
|
cancellationToken.ThrowIfCancellationRequested();
|
|
ArgumentException.ThrowIfNullOrWhiteSpace(group);
|
|
var groups = await ReadPageAsync(account, 10000, contains: group, groupsOnly: true,
|
|
cancellationToken: cancellationToken).ConfigureAwait(false);
|
|
if (groups.HasMore)
|
|
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Group lookup is truncated; use a stable @chatroom username.");
|
|
var exact = groups.Contacts.Where(item => item.Username == group || item.DisplayName == group).ToArray();
|
|
var selected = WechatOperationPolicy.RequireUnique(exact, "database group");
|
|
var parameters = WechatGroupMemberQuery.Parameters(selected.Username, limit, offset);
|
|
var database = RequireContactDatabase(account);
|
|
var rows = await SqlCipherDatabaseReader.QueryRowsAsync(ContactDatabasePath(account), database.EncKey,
|
|
WechatGroupMemberQuery.Sql, parameters, cancellationToken).ConfigureAwait(false);
|
|
var members = rows.Select(row =>
|
|
{
|
|
var id = Convert.ToInt64(row.GetValueOrDefault("member_id"), System.Globalization.CultureInfo.InvariantCulture);
|
|
var username = row.GetValueOrDefault("username") as string ?? throw new WxAgentException(
|
|
WxAgentErrorCode.DatabaseOpenFailed, "A group-member row has no stable username.");
|
|
var remark = row.GetValueOrDefault("remark") as string;
|
|
var nickname = row.GetValueOrDefault("nick_name") as string;
|
|
var owner = Convert.ToString(row.GetValueOrDefault("owner"), System.Globalization.CultureInfo.InvariantCulture);
|
|
var displayName = !string.IsNullOrWhiteSpace(remark) ? remark : !string.IsNullOrWhiteSpace(nickname) ? nickname : username;
|
|
var isOwner = string.Equals(owner, username, StringComparison.Ordinal) ||
|
|
long.TryParse(owner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var ownerId) && ownerId == id;
|
|
return new WechatDatabaseGroupMember(id, username, displayName, isOwner);
|
|
}).ToArray();
|
|
return WechatGroupMemberQuery.Page(selected.Username, members, limit, offset);
|
|
}
|
|
|
|
private static DatabaseKeyEvidence RequireContactDatabase(AccountKeySet account)
|
|
{
|
|
var databases = account.Databases.Where(item =>
|
|
string.Equals(item.RelativePath.Replace('\\', '/'), "contact/contact.db", StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
if (databases.Length != 1)
|
|
throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Exactly one contact.db key is required for the selected account.");
|
|
return databases[0];
|
|
}
|
|
|
|
private static string ContactDatabasePath(AccountKeySet account) =>
|
|
Path.GetFullPath(Path.Combine(account.AccountRootPath, "contact", "contact.db"));
|
|
}
|