112 lines
6.1 KiB
C#
112 lines
6.1 KiB
C#
using System.Globalization;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using WxAgent.Core;
|
|
|
|
namespace WxAgent.Windows;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<RemoteDataCollection> CollectAsync(
|
|
string accountId,
|
|
IReadOnlyList<RemoteReportingScope> 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<string, long>(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<string, long>(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();
|
|
}
|
|
}
|