feat: database-backed message model with multi-account key cache
- Add Windows 4.1.x Config.Cipher pointer-chain key extraction with cross-chunk
MemoryPatternScanner and bounded blob XOR decoding; keys still require per-DB
page-1 HMAC verification before caching.
- Cache keys by account fingerprint + database relative path; db scan --save
skips memory scanning entirely once all discovered databases are cached.
- Add DbMessage model (localId, serverId, type, timestamp, sender, display name,
avatar, isSelf) read read-only from Msg_{md5(chat)} via SQLCipher with hex/zstd
content decoding and contact.db name resolution.
- CLI: db messages / db contacts / db schema with masked defaults; include the
earlier chat send --session wiring. 97 Core tests pass; real-machine validated.
This commit is contained in:
+154
-21
@@ -101,7 +101,7 @@ try
|
||||
}
|
||||
case "listener-smoke":
|
||||
{
|
||||
var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken, HasOption(args, "--independent"));
|
||||
var result = await WechatChatClient.ListenerSmokeAsync(GetRequiredOption(args, "--output"), cancellationToken, HasOption(args, "--independent"), GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant);
|
||||
WriteJson(result);
|
||||
return result.Sent && result.MatchingEvents == 1 && result.EventsAfterRestart == 0
|
||||
&& result.Snapshots >= 2 && result.CheckpointSaved ? 0 : 2;
|
||||
@@ -273,7 +273,7 @@ try
|
||||
}
|
||||
case "chat" when args[1] == "send":
|
||||
{
|
||||
var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken);
|
||||
var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken, GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant);
|
||||
WriteJson(ToMessageOutput(message, includeContent: false));
|
||||
return 0;
|
||||
}
|
||||
@@ -368,24 +368,48 @@ try
|
||||
case "db" when args[1] == "scan":
|
||||
{
|
||||
var dataRoot = GetOption(args, "--data-root");
|
||||
var result = WechatDatabaseScanner.Scan(dataRoot, cancellationToken);
|
||||
string? savedTo = null;
|
||||
if (HasOption(args, "--save"))
|
||||
var savedTo = HasOption(args, "--save") ? GetOption(args, "--key-file") ?? DatabaseKeyStore.DefaultPath : null;
|
||||
var existing = savedTo is null ? Array.Empty<AccountKeySet>() : await DatabaseKeyStore.LoadAsync(savedTo, cancellationToken);
|
||||
var verified = KeyCachePlanner.VerifiedKeys(existing);
|
||||
|
||||
var accounts = WechatDatabaseDiscovery.FindAccountRoots(dataRoot, cancellationToken);
|
||||
var discovered = accounts.SelectMany(account => account.Databases.Select(database =>
|
||||
new AccountDatabaseEntry(account.Fingerprint, database.RelativePath))).ToArray();
|
||||
var scanned = KeyCachePlanner.HasPending(discovered, verified);
|
||||
|
||||
var processCount = 0;
|
||||
var candidateCount = 0;
|
||||
var verificationAttempts = 0;
|
||||
AccountKeySet[] finalAccounts;
|
||||
if (scanned)
|
||||
{
|
||||
savedTo = GetOption(args, "--key-file") ?? DatabaseKeyStore.DefaultPath;
|
||||
var existing = await DatabaseKeyStore.LoadAsync(savedTo, cancellationToken);
|
||||
var merged = AccountKeySetMerge.Merge(existing, result.Accounts);
|
||||
await DatabaseKeyStore.SaveAsync(merged, savedTo, cancellationToken);
|
||||
var result = WechatDatabaseScanner.Scan(dataRoot, cancellationToken, verified);
|
||||
processCount = result.ProcessCount;
|
||||
candidateCount = result.CandidateCount;
|
||||
verificationAttempts = result.VerificationAttempts;
|
||||
finalAccounts = savedTo is null
|
||||
? result.Accounts.ToArray()
|
||||
: AccountKeySetMerge.Merge(existing, result.Accounts).ToArray();
|
||||
}
|
||||
else
|
||||
{
|
||||
finalAccounts = existing.ToArray();
|
||||
}
|
||||
|
||||
if (savedTo is not null)
|
||||
{
|
||||
await DatabaseKeyStore.SaveAsync(finalAccounts, savedTo, cancellationToken);
|
||||
}
|
||||
|
||||
WriteJson(new
|
||||
{
|
||||
result.ProcessCount,
|
||||
result.CandidateCount,
|
||||
result.DatabaseCount,
|
||||
verifiedDatabaseCount = result.Accounts.Sum(account => account.Databases.Count),
|
||||
scanned,
|
||||
processCount,
|
||||
candidateCount,
|
||||
verificationAttempts,
|
||||
verifiedDatabaseCount = finalAccounts.Sum(account => account.Databases.Count),
|
||||
savedTo,
|
||||
accounts = result.Accounts.Select(account => new
|
||||
accounts = finalAccounts.Select(account => new
|
||||
{
|
||||
account.AccountRootFingerprint,
|
||||
account.WechatVersion,
|
||||
@@ -398,7 +422,7 @@ try
|
||||
})
|
||||
})
|
||||
});
|
||||
return result.Accounts.Any(account => account.Databases.Count > 0) ? 0 : 3;
|
||||
return finalAccounts.Any(account => account.Databases.Count > 0) ? 0 : 3;
|
||||
}
|
||||
case "db" when args[1] == "status":
|
||||
{
|
||||
@@ -438,6 +462,98 @@ try
|
||||
WriteJson(new { account = account.AccountRootFingerprint, database = database.RelativePath, metadata });
|
||||
return 0;
|
||||
}
|
||||
case "db" when args[1] == "schema":
|
||||
{
|
||||
var accountId = GetRequiredOption(args, "--account");
|
||||
var relativePath = GetRequiredOption(args, "--database").Replace('\\', '/');
|
||||
var tableName = GetRequiredOption(args, "--table");
|
||||
var keyFile = GetOption(args, "--key-file");
|
||||
if (!tableName.All(character => char.IsAsciiLetterOrDigit(character) || character == '_'))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Table name may only contain letters, digits and underscores.");
|
||||
}
|
||||
|
||||
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, 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 database = account.Databases.SingleOrDefault(item => string.Equals(item.RelativePath, relativePath, StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "Database path was not found for the selected account.");
|
||||
var fullPath = Path.GetFullPath(Path.Combine(account.AccountRootPath, database.RelativePath.Replace('/', Path.DirectorySeparatorChar)));
|
||||
var rows = await SqlCipherDatabaseReader.QueryRowsAsync(fullPath, database.EncKey, $"PRAGMA table_info({tableName});", null, cancellationToken);
|
||||
WriteJson(new
|
||||
{
|
||||
account = account.AccountRootFingerprint,
|
||||
database = database.RelativePath,
|
||||
table = tableName,
|
||||
columns = rows.Select(row => new
|
||||
{
|
||||
name = row.GetValueOrDefault("name"),
|
||||
type = row.GetValueOrDefault("type"),
|
||||
notNull = row.GetValueOrDefault("notnull")
|
||||
})
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
case "db" when args[1] == "messages":
|
||||
{
|
||||
var accountId = GetRequiredOption(args, "--account");
|
||||
var chatId = GetRequiredOption(args, "--chat");
|
||||
var keyFile = GetOption(args, "--key-file");
|
||||
var limit = GetPositiveIntOption(args, "--limit", 50, 500);
|
||||
var includeContent = HasOption(args, "--include-content");
|
||||
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, 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 messages = await WechatMessageDbReader.ReadAsync(account.AccountRootPath, account.Databases, chatId, limit, cancellationToken);
|
||||
WriteJson(new
|
||||
{
|
||||
chatId,
|
||||
count = messages.Count,
|
||||
messages = messages.Select(message => new
|
||||
{
|
||||
message.LocalId,
|
||||
message.ServerId,
|
||||
message.Type,
|
||||
timestamp = message.Timestamp.ToString("o", System.Globalization.CultureInfo.InvariantCulture),
|
||||
sender = includeContent ? message.SenderWxId : MaskIdentifier(message.SenderWxId),
|
||||
message.IsSelf,
|
||||
senderName = includeContent ? message.SenderName : null,
|
||||
senderAvatarUrl = includeContent ? message.SenderAvatarUrl : null,
|
||||
content = includeContent ? message.Content : null
|
||||
})
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
case "db" when args[1] == "contacts":
|
||||
{
|
||||
var accountId = GetRequiredOption(args, "--account");
|
||||
var keyFile = GetOption(args, "--key-file");
|
||||
var contains = GetOption(args, "--contains");
|
||||
var includeContent = HasOption(args, "--include-content");
|
||||
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, 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 contactDatabase = account.Databases.SingleOrDefault(item => string.Equals(item.RelativePath, "contact/contact.db", StringComparison.OrdinalIgnoreCase))
|
||||
?? throw new WxAgentException(WxAgentErrorCode.DatabaseKeyNotFound, "contact.db was not found in the key store.");
|
||||
var fullPath = Path.GetFullPath(Path.Combine(account.AccountRootPath, "contact", "contact.db"));
|
||||
var sql = contains is null
|
||||
? "SELECT username, nick_name, remark, small_head_url FROM contact ORDER BY username LIMIT 200;"
|
||||
: "SELECT username, nick_name, remark, small_head_url FROM contact WHERE remark LIKE $like OR nick_name LIKE $like OR username LIKE $like LIMIT 200;";
|
||||
var parameters = contains is null ? null : new[] { new KeyValuePair<string, object?>("$like", "%" + contains + "%") };
|
||||
var rows = await SqlCipherDatabaseReader.QueryRowsAsync(fullPath, contactDatabase.EncKey, sql, parameters, cancellationToken);
|
||||
WriteJson(new
|
||||
{
|
||||
count = rows.Count,
|
||||
contacts = rows.Select(row => new
|
||||
{
|
||||
username = includeContent ? row.GetValueOrDefault("username") : MaskIdentifier(row.GetValueOrDefault("username") as string),
|
||||
nickName = includeContent ? row.GetValueOrDefault("nick_name") : null,
|
||||
remark = includeContent ? row.GetValueOrDefault("remark") : null,
|
||||
avatarUrl = includeContent ? row.GetValueOrDefault("small_head_url") : null
|
||||
})
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
default:
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help.");
|
||||
}
|
||||
@@ -475,7 +591,7 @@ static int ValidateCommandLine(string[] values)
|
||||
|
||||
if (values[0] == "listener-smoke")
|
||||
{
|
||||
ValidateOptions(values, 1, ["--output", "--timeout"], ["--independent"]);
|
||||
ValidateOptions(values, 1, ["--output", "--session", "--timeout"], ["--independent"]);
|
||||
return 60;
|
||||
}
|
||||
|
||||
@@ -550,7 +666,7 @@ static int ValidateCommandLine(string[] values)
|
||||
ValidateOptions(values, 2, ["--to", "--path", "--confirm", "--timeout"], []);
|
||||
return 60;
|
||||
case "send":
|
||||
ValidateOptions(values, 2, ["--text", "--timeout"], []);
|
||||
ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []);
|
||||
return 30;
|
||||
case "reply-latest":
|
||||
ValidateOptions(values, 2, ["--text", "--timeout"], []);
|
||||
@@ -611,6 +727,15 @@ static int ValidateCommandLine(string[] values)
|
||||
case "query":
|
||||
ValidateOptions(values, 2, ["--account", "--database", "--key-file", "--timeout"], []);
|
||||
return 30;
|
||||
case "messages":
|
||||
ValidateOptions(values, 2, ["--account", "--chat", "--key-file", "--limit", "--timeout"], ["--include-content"]);
|
||||
return 60;
|
||||
case "contacts":
|
||||
ValidateOptions(values, 2, ["--account", "--key-file", "--contains", "--timeout"], ["--include-content"]);
|
||||
return 60;
|
||||
case "schema":
|
||||
ValidateOptions(values, 2, ["--account", "--database", "--table", "--key-file", "--timeout"], []);
|
||||
return 60;
|
||||
default:
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown database command. Run WxAgent.Host help.");
|
||||
}
|
||||
@@ -655,6 +780,11 @@ 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 string? MaskIdentifier(string? identifier) =>
|
||||
string.IsNullOrEmpty(identifier)
|
||||
? identifier
|
||||
: "sha256:" + Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(identifier)))[..16];
|
||||
|
||||
static bool HasOption(string[] values, string name) => Array.IndexOf(values, name) >= 0;
|
||||
|
||||
static int GetTimeoutSeconds(string[] values, int fallback)
|
||||
@@ -724,7 +854,7 @@ WxAgent.Host commands:
|
||||
m5-smoke [--timeout 60]
|
||||
tray-status [--timeout 30]
|
||||
window-status [--timeout 30]
|
||||
listener-smoke --output <new-dir> [--independent] [--timeout 60]
|
||||
listener-smoke --output <new-dir> [--session <name>] [--independent] [--timeout 60]
|
||||
recovery-smoke [--timeout 30]
|
||||
recover-ui [--timeout 30]
|
||||
stability-smoke [--seconds 15] [--sample-seconds 5] [--output <dir>]
|
||||
@@ -733,7 +863,7 @@ WxAgent.Host commands:
|
||||
group verify-at-all --group <name> --message <text> [--timeout 30]
|
||||
chat send-url-card --to <name> --url <http-url> [--message <text>] --confirm CONFIRM [--timeout 60]
|
||||
chat send-audio --to <name> --path <audio-file> --confirm CONFIRM [--timeout 60]
|
||||
chat send --text <one-line-text> [--timeout 30]
|
||||
chat send --text <one-line-text> [--session <name>] [--timeout 30]
|
||||
chat reply-latest --text <one-line-text> [--timeout 60]
|
||||
chat send-file --path <file> [--timeout 60]
|
||||
chat send-image --path <image> [--timeout 60]
|
||||
@@ -748,8 +878,11 @@ WxAgent.Host commands:
|
||||
db scan [--data-root <xwechat_files>] [--save] [--key-file <path>] [--timeout 120]
|
||||
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 contacts --account <fingerprint> [--contains <text>] [--include-content] [--key-file <path>] [--timeout 60]
|
||||
|
||||
Chat commands are restricted to File Transfer Assistant. Message content is omitted unless --include-content is explicit.
|
||||
Chat commands default to File Transfer Assistant; send/monitor and listener-smoke accept --session.
|
||||
Message content is omitted unless --include-content is explicit.
|
||||
M4 modifying operations are library APIs and require the exact confirmation token CONFIRM.
|
||||
Database keys are never printed. db scan only saves verified keys when --save is present.
|
||||
Database keys are never printed. db scan only saves verified keys when --save is present. Already verified accounts are skipped by the key cache.
|
||||
""");
|
||||
|
||||
Reference in New Issue
Block a user