using WxAgent.Core; namespace WxAgent.Windows; public static class WechatContactDbReader { public static async Task 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 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 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 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")); }