using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; using WxAgent.Core; namespace WxAgent.Windows; /// /// Collects authorized messages directly from verified read-only database keys. /// It never opens a WeChat window, changes the active account, or writes to the /// source database; unavailable shards are returned as an explicit partial result. /// public sealed class DatabaseMessageSyncCollector : IRemoteDataCollector { private readonly string? keyFile; private readonly int perChatLimit; private readonly int overlapRows; public DatabaseMessageSyncCollector(string? keyFile = null, int perChatLimit = 100, int overlapRows = 1) { if (perChatLimit is < 1 or > 500) throw new ArgumentOutOfRangeException(nameof(perChatLimit)); if (overlapRows is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(overlapRows)); this.keyFile = keyFile; this.perChatLimit = perChatLimit; this.overlapRows = overlapRows; } public async Task CollectAsync( string accountId, IReadOnlyList scopes, RemoteDataSyncCheckpoint checkpoint, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(accountId)) throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "A database sync account id is required."); var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken).ConfigureAwait(false); var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase)); if (account is null) throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "The verified database account does not match the requested sync account."); if (account.Databases.Any(database => database.Confidence != KeyBindingConfidence.PageHmacVerified)) throw new WxAgentException(WxAgentErrorCode.DatabaseOpenFailed, "The account contains a database key without page-1 HMAC verification."); // The verified account fingerprint is the stable source generation for v1; // a future key-cache version can deliberately rotate it for a rebind. var sourceGeneration = account.AccountRootFingerprint; var cursors = string.Equals(checkpoint.SourceGeneration, sourceGeneration, StringComparison.Ordinal) ? checkpoint.ConfirmedCursors : new Dictionary(StringComparer.Ordinal); var requestedScopes = scopes.DistinctBy(scope => $"{scope.ChatType}:{scope.ChatId}").ToArray(); var directory = await WechatSessionDbReader.ReadAuthorizedAsync(account, requestedScopes, cancellationToken).ConfigureAwait(false); var chatScopes = requestedScopes.Any(scope => scope.ChatId == "*") ? directory.Conversations.Select(entry => new RemoteReportingScope(entry.ChatId, entry.ChatType)).ToArray() : requestedScopes; var chatIds = chatScopes.Select(scope => scope.ChatId).Distinct(StringComparer.Ordinal).ToArray(); var page = await WechatMessageDbReader.ReadIncrementalAsync( account.AccountRootPath, account.Databases, chatIds, cursors, perChatLimit, cancellationToken, overlapRows).ConfigureAwait(false); var scopeByChat = chatScopes .GroupBy(scope => scope.ChatId, StringComparer.Ordinal) .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); var observedAt = DateTimeOffset.UtcNow; var conversations = directory.Conversations.Select(entry => new RemoteSyncConversation( entry.ChatId, entry.ChatType, entry.Title, entry.LastActivityAt, "session/session.db/SessionTable", observedAt, entry.DirectoryState)).ToArray(); var messages = page.Messages.Select(message => { var scope = scopeByChat.GetValueOrDefault(message.ChatId) ?? new RemoteReportingScope(message.ChatId, ReportingChatType.Private); var sourceMessageId = message.LocalId.ToString(CultureInfo.InvariantCulture); var messageId = message.ServerId > 0 ? $"{message.ChatId}:server:{message.ServerId.ToString(CultureInfo.InvariantCulture)}" : $"{message.ChatId}:local:{sourceMessageId}"; var text = message.Content ?? string.Empty; return new RemoteSyncMessage( messageId, message.ChatId, scope.ChatType, sourceMessageId, message.IsSelf == true ? "outgoing" : "incoming", message.Type.ToString(CultureInfo.InvariantCulture), text, message.Timestamp, observedAt, account.WechatVersion ?? "unknown", HashMessage(messageId, text, message.Timestamp)); }).ToArray(); var nextCursors = new Dictionary(page.NextCursors, StringComparer.Ordinal) { [WechatSessionDbReader.DirectoryCursorKey] = directory.DirectoryCursor }; var directoryChanged = directory.DirectoryCursor != cursors.GetValueOrDefault(WechatSessionDbReader.DirectoryCursorKey); var unavailable = page.UnavailableDatabases .Concat(directory.UnavailableDatabases) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(item => item, StringComparer.OrdinalIgnoreCase) .ToArray(); return new RemoteDataCollection(sourceGeneration, nextCursors, conversations, messages, page.IsComplete && directory.IsComplete, page.NewMessageCount > 0 || directoryChanged, unavailable); } private static string HashMessage(string messageId, string text, DateTimeOffset sourceTime) { var canonical = JsonSerializer.Serialize(new { messageId, text, sourceTime = sourceTime.ToUniversalTime().ToString("O") }); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))).ToLowerInvariant(); } }