feat: expand merged chat records directly from read-only databases

Add db merged with exact account/chat/local-ID selection and shard ambiguity checks. Preserve Int64 composite message types and parse bounded, DTD-free embedded record XML, including duplicate messages and source metadata. Revalidate cached keys against current page-1 HMAC; replace the SQL write probe with sqlite3_db_readonly. Core tests: 129 passed. Windows Session 0 database validation: 5/5; all 16 text/sender/source-ID/timestamp entries matched independent XML parsing. Read-only UI baseline: 3/3; zero sends.
This commit is contained in:
2026-09-06 20:10:04 +08:00
parent e5548bd471
commit 30df7ae433
15 changed files with 775 additions and 24 deletions
+54
View File
@@ -545,6 +545,30 @@ try
});
return 0;
}
case "db" when args[1] == "merged":
{
var accountId = GetRequiredOption(args, "--account");
var chatId = GetRequiredOption(args, "--chat");
if (!long.TryParse(GetRequiredOption(args, "--local-id"), System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture, out var localId) || localId <= 0)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "--local-id must be a positive 64-bit integer.");
var accounts = await DatabaseKeyStore.LoadAsync(GetOption(args, "--key-file"), cancellationToken);
var account = accounts.SingleOrDefault(item => string.Equals(item.AccountRootFingerprint, accountId, StringComparison.OrdinalIgnoreCase))
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Account fingerprint was not found in the key store.");
var result = await WechatMessageDbReader.ReadMergedAsync(account.AccountRootPath, account.Databases, chatId, localId,
cancellationToken, GetOption(args, "--database"));
var includeContent = HasOption(args, "--include-content");
WriteJson(new
{
account = account.AccountRootFingerprint,
database = result.DatabaseRelativePath,
chatId = includeContent ? chatId : MaskIdentifier(chatId),
recordId = $"{account.AccountRootFingerprint}:{result.DatabaseRelativePath}:{localId}",
parent = new { result.Parent.LocalId, result.Parent.ServerId, result.Parent.Type, result.Parent.Timestamp },
record = ToMergedOutput(result.Record, includeContent)
});
return 0;
}
case "db" when args[1] == "messages":
{
var accountId = GetRequiredOption(args, "--account");
@@ -793,6 +817,9 @@ static int ValidateCommandLine(string[] values)
case "query":
ValidateOptions(values, 2, ["--account", "--database", "--key-file", "--timeout"], []);
return 30;
case "merged":
ValidateOptions(values, 2, ["--account", "--chat", "--local-id", "--database", "--key-file", "--timeout"], ["--include-content"]);
return 60;
case "messages":
ValidateOptions(values, 2, ["--account", "--chat", "--key-file", "--limit", "--timeout"], ["--include-content"]);
return 60;
@@ -846,6 +873,32 @@ static string? GetOption(string[] values, string name)
static string GetRequiredOption(string[] values, string name) =>
GetOption(values, name) ?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing required option {name}.");
static object ToMergedOutput(WechatMergedChat record, bool includeContent) => new
{
title = includeContent ? record.Title : null,
description = includeContent ? record.Description : null,
count = record.Messages.Count,
messages = record.Messages.Select(message => new
{
path = message.Path,
dataId = message.DataId,
dataType = message.DataType,
senderId = MaskIdentifier(message.SenderHash ?? message.SenderName),
senderName = includeContent ? message.SenderName : null,
sourceLocalId = message.SourceLocalId,
sourceServerId = message.SourceServerId,
timestamp = message.Timestamp,
displayTime = message.DisplayTime,
text = includeContent ? message.Text : null,
length = message.Text?.Length,
contentAvailable = message.Text is not null,
title = includeContent ? message.Title : null,
format = message.Format,
sizeBytes = message.SizeBytes,
nestedRecord = message.NestedRecord is null ? null : ToMergedOutput(message.NestedRecord, includeContent)
})
};
static string? MaskIdentifier(string? identifier) =>
string.IsNullOrEmpty(identifier)
? identifier
@@ -950,6 +1003,7 @@ WxAgent.Host commands:
db status [--key-file <path>] [--timeout 30]
db query --account <fingerprint> --database <relative-path> [--key-file <path>] [--timeout 30]
db messages --account <fingerprint> --chat <wxid> [--limit 50] [--include-content] [--key-file <path>] [--timeout 60]
db merged --account <fingerprint> --chat <wxid> --local-id <id> [--database <relative-path>] [--include-content] [--key-file <path>] [--timeout 60]
db contacts --account <fingerprint> [--contains <text>] [--include-content] [--key-file <path>] [--timeout 60]
Chat commands default to File Transfer Assistant; send/monitor and listener-smoke accept --session.