Files

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