Files
wx-win-agent/node-agent/WxAgent.Host/Program.cs
T
rogee 0d29b828bb
Build web service image / build (push) Successful in 48s
feat: harden control-plane deployment
2026-09-12 11:09:42 +08:00

1088 lines
52 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using WxAgent.Core;
using WxAgent.Windows;
using WxAgent.Service;
using WxAgent.Host;
var jsonOptions = new JsonSerializerOptions { WriteIndented = true };
jsonOptions.Converters.Add(new JsonStringEnumConverter());
var jsonLineOptions = new JsonSerializerOptions();
jsonLineOptions.Converters.Add(new JsonStringEnumConverter());
using var shutdown = new CancellationTokenSource();
Console.CancelKeyPress += (_, eventArgs) =>
{
eventArgs.Cancel = true;
shutdown.Cancel();
};
try
{
if (args.Length == 0 || args[0] is "help" or "--help" or "-h")
{
PrintHelp();
return 0;
}
if (args[0] == "serve")
{
ValidateOptions(args, 1, ["--config"], []);
var configPath = Path.GetFullPath(GetRequiredOption(args, "--config"));
var serviceOptions = JsonSerializer.Deserialize<ServiceOptions>(
await File.ReadAllTextAsync(configPath, shutdown.Token), ServiceHost.ConfigurationJson)
?? throw new ArgumentException("A service configuration is required.");
await using var app = ServiceHost.Build(serviceOptions, new WindowsAgentBackend(new AccountBindingStore(serviceOptions)),
logPath: Path.Combine(Path.GetDirectoryName(configPath)!, "wxagent.log"));
await app.StartAsync(shutdown.Token);
try { await Task.Delay(Timeout.InfiniteTimeSpan, shutdown.Token); }
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
await app.StopAsync(CancellationToken.None);
return 0;
}
var defaultTimeoutSeconds = ValidateCommandLine(args);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(shutdown.Token);
timeout.CancelAfter(TimeSpan.FromSeconds(GetTimeoutSeconds(args, defaultTimeoutSeconds)));
var cancellationToken = timeout.Token;
switch (args[0])
{
case "remote":
{
WriteJson(await RemoteCliCommands.RunAsync(args, cancellationToken));
return 0;
}
case "doctor":
{
var report = WechatDoctor.Run(cancellationToken);
WriteJson(report);
return report.Errors.Count == 0 ? 0 : 2;
}
case "diagnose":
{
var directory = Path.GetFullPath(GetRequiredOption(args, "--output"));
Directory.CreateDirectory(directory);
var snapshot = await WechatUiInspector.CaptureAsync(Path.Combine(directory, "ui-tree.json"), cancellationToken);
var baseline = snapshot;
if (GetOption(args, "--baseline") is { } baselinePath)
{
await using var input = File.OpenRead(baselinePath);
baseline = await JsonSerializer.DeserializeAsync<UiNodeSnapshot>(input,
new JsonSerializerOptions { MaxDepth = 128 }, cancellationToken)
?? throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Baseline UI snapshot is empty.");
}
var report = new
{
correlationId = Guid.NewGuid().ToString("N"),
stage = "ui-compatibility-diagnostics",
capturedAt = DateTimeOffset.UtcNow,
doctor = WechatDoctor.Run(cancellationToken),
nativeWindows = WechatChatClient.InspectNativeWindows(),
comparison = UiSnapshotComparison.Compare(baseline, snapshot)
};
await File.WriteAllTextAsync(Path.Combine(directory, "diagnostics.json"),
JsonSerializer.Serialize(report, jsonOptions), cancellationToken);
WriteJson(report);
return report.doctor.Errors.Count == 0 && report.comparison.MissingRequiredControls.Count == 0 ? 0 : 2;
}
case "inspect-ui":
{
var output = GetRequiredOption(args, "--output");
var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken, GetOption(args, "--window-title"));
WriteJson(new { output = Path.GetFullPath(output), nodes = CountNodes(snapshot), sanitized = true });
return 0;
}
case "smoke":
{
var output = GetOption(args, "--output") ?? Path.Combine("artifacts", "ui-tree.json");
var report = WechatDoctor.Run(cancellationToken);
var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken);
var readOnly = HasOption(args, "--read-only");
ChatMessageSnapshot? confirmed = null;
if (!readOnly && report.Errors.Count == 0)
{
var marker = $"wxagent-smoke-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}";
confirmed = await WechatChatClient.SendTextAsync(marker, cancellationToken);
}
WriteJson(new
{
scope = "M1",
doctorErrors = report.Errors,
output = Path.GetFullPath(output),
nodes = CountNodes(snapshot),
sanitized = true,
sentTo = confirmed is null ? null : "file-transfer-assistant",
Fingerprint = confirmed?.Fingerprint,
sendSkipped = readOnly,
confirmed = confirmed is not null
});
return report.Errors.Count == 0 ? 0 : 2;
}
case "tray-status":
{
WriteJson(WechatChatClient.InspectTray(cancellationToken));
return 0;
}
case "window-status":
{
WriteJson(WechatChatClient.InspectNativeWindows());
return 0;
}
case "listener-smoke":
{
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;
}
case "recovery-smoke":
{
var checks = await WechatChatClient.RecoverySmokeAsync(cancellationToken);
WriteJson(checks);
return checks.All(check => check.TransitionObserved && check.SameInstance && check.MainViewFound
&& check.SessionsFound && check.MessagesFound) ? 0 : 2;
}
case "recover-ui":
{
var result = await WechatChatClient.RecoverUiAsync(cancellationToken);
WriteJson(result);
return result.Success ? 0 : 2;
}
case "stability-smoke":
{
var seconds = GetPositiveIntOption(args, "--seconds", 15, 300);
var sampleSeconds = GetPositiveIntOption(args, "--sample-seconds", 5, seconds);
var output = GetOption(args, "--output") ?? Path.Combine("artifacts", "m6-stability-smoke");
var recovery = await WechatChatClient.RecoverUiAsync(cancellationToken);
var report = await WechatStabilityRunner.RunAsync(
TimeSpan.FromSeconds(seconds),
TimeSpan.FromSeconds(sampleSeconds),
output,
cancellationToken);
WriteJson(new
{
scope = "M6",
recovery.Success,
report.Healthy,
sampleCount = report.Samples.Count,
report.MessageEvents,
report.ReconnectEvents,
report.WorkingSetGrowthBytes,
report.HandleGrowth,
report.ThreadGrowth,
report.Findings,
output = Path.GetFullPath(output),
privacy = "message content omitted"
});
return report.Healthy ? 0 : 2;
}
case "endurance":
{
var hours = GetPositiveIntOption(args, "--hours", 24, 72);
var sampleSeconds = GetPositiveIntOption(args, "--sample-seconds", 60, 3600);
var output = GetOption(args, "--output") ?? Path.Combine("artifacts", $"endurance-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}");
await WechatChatClient.RecoverUiAsync(cancellationToken);
var report = await WechatStabilityRunner.RunAsync(
TimeSpan.FromHours(hours),
TimeSpan.FromSeconds(sampleSeconds),
output,
cancellationToken);
WriteJson(report with { Samples = [] });
return report.Healthy ? 0 : 2;
}
case "m5-smoke":
{
var momentsWindow = await WechatChatClient.OpenMomentsAsync(cancellationToken);
await WechatChatClient.RefreshMomentsAsync(cancellationToken);
var moments = await WechatChatClient.GetMomentsAsync(50, cancellationToken: cancellationToken);
await WechatChatClient.CloseMomentsAsync(cancellationToken);
var sample = new ChatMessageSnapshot("文档 https://example.com", "m5-smoke", 0, ChatMessageType.Link);
var confirmationGuarded = false;
try
{
WechatOperationPolicy.RequireConfirmation(null, "m5 smoke write operation");
}
catch (WxAgentException exception) when (exception.Code == WxAgentErrorCode.InvalidArgument)
{
confirmationGuarded = true;
}
WriteJson(new
{
scope = "M5",
momentsWindow = momentsWindow.Kind,
momentCount = moments.Count,
urlParsed = WechatMessageContentParser.GetUrl(sample)?.Host == "example.com",
markdownRendered = WechatMessageContentParser.ToMarkdown(sample).Contains("https://example.com", StringComparison.Ordinal),
confirmationGuarded,
privacy = "moment authors and content omitted"
});
return confirmationGuarded ? 0 : 2;
}
case "m4-smoke":
{
var windows = await WechatChatClient.GetSubWindowsAsync(cancellationToken);
var history = await WechatChatClient.GetHistoryMessageAsync("文件传输助手", 3, cancellationToken: cancellationToken);
await WechatChatClient.OpenSessionInSubWindowAsync("文件传输助手", cancellationToken);
await Task.Delay(500, cancellationToken);
var windowsAfterDoubleClick = await WechatChatClient.GetSubWindowsAsync(cancellationToken);
await WechatChatClient.CloseSubWindowAsync("文件传输助手", cancellationToken);
var contacts = await WechatChatClient.GetFriendsAsync(5000, cancellationToken);
var newFriends = await WechatChatClient.GetNewFriendsAsync(true, cancellationToken);
var groups = await WechatChatClient.GetRecentGroupsAsync(cancellationToken);
var myInfo = await WechatChatClient.GetMyInfoAsync(cancellationToken);
await WechatChatClient.SwitchToChatsAsync(cancellationToken);
var confirmationGuarded = false;
try
{
WechatOperationPolicy.RequireConfirmation(null, "m4 smoke destructive operation");
}
catch (WxAgentException exception) when (exception.Code == WxAgentErrorCode.InvalidArgument)
{
confirmationGuarded = true;
}
WriteJson(new
{
scope = "M4",
subWindows = windowsAfterDoubleClick.Select(window => new { window.Kind, window.ProcessId }),
subWindowOpened = windowsAfterDoubleClick.Count > windows.Count,
historyCount = history.Count,
contactCount = contacts.Count,
newFriendCount = newFriends.Count,
recentGroupCount = groups.Count,
accountDetected = !string.IsNullOrWhiteSpace(myInfo.DisplayName),
confirmationGuarded,
privacy = "names and account identifiers omitted"
});
return confirmationGuarded
&& !string.IsNullOrWhiteSpace(myInfo.DisplayName)
&& history.Count > 0
&& windowsAfterDoubleClick.Count > windows.Count ? 0 : 2;
}
case "group" when args[1] == "at-all":
{
var group = GetRequiredOption(args, "--group");
var message = GetRequiredOption(args, "--message");
var confirmation = GetRequiredOption(args, "--confirm");
var result = await WechatChatClient.AtAllAsync(group, message, confirmation, cancellationToken);
WriteJson(result);
return result.Success ? 0 : 2;
}
case "group" when args[1] == "verify-at-all":
{
var group = GetRequiredOption(args, "--group");
var message = GetRequiredOption(args, "--message");
var history = await WechatChatClient.GetHistoryMessageAsync(group, 10, cancellationToken: cancellationToken);
var found = history.Any(item => item.Text.Contains("@所有人", StringComparison.Ordinal)
&& item.Text.Contains(message, StringComparison.Ordinal));
WriteJson(new { found, checkedMessages = history.Count, privacy = "message text omitted" });
return found ? 0 : 2;
}
case "chat" when args[1] == "send-url-card":
{
var recipient = GetRequiredOption(args, "--to");
var url = new Uri(GetRequiredOption(args, "--url"), UriKind.Absolute);
var result = await WechatChatClient.SendUrlCardAsync(
url,
[recipient],
GetOption(args, "--message"),
cancellationToken);
WriteJson(result);
return result.Success ? 0 : 2;
}
case "chat" when args[1] == "send-audio":
{
var result = await WechatChatClient.SendAudioAsync(
GetRequiredOption(args, "--to"),
GetRequiredOption(args, "--path"),
GetRequiredOption(args, "--confirm"),
cancellationToken);
WriteJson(result);
return result.Success ? 0 : 2;
}
case "chat" when args[1] == "send":
{
var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken, GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant);
WriteJson(ToMessageOutput(message, includeContent: false));
return 0;
}
case "chat" when args[1] == "reply-latest":
{
var message = await WechatChatClient.ReplyToLatestAsync(GetRequiredOption(args, "--text"), cancellationToken, GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant);
WriteJson(ToMessageOutput(message, includeContent: false));
return 0;
}
case "chat" when args[1] == "mention":
{
var session = GetRequiredOption(args, "--session");
var member = GetRequiredOption(args, "--member");
var text = GetOption(args, "--text");
var confirmation = GetRequiredOption(args, "--confirm");
var result = await WechatChatClient.MentionMemberAsync(session, member, text, confirmation, cancellationToken);
WriteJson(result);
return result.Success ? 0 : 2;
}
case "chat" when args[1] is "send-file" or "send-image":
{
var path = GetRequiredOption(args, "--path");
var session = GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant;
if (args[1] == "send-image" && !IsImagePath(path))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "send-image accepts png, jpg, jpeg, gif, bmp, or webp files.");
}
var message = args[1] == "send-image"
? await WechatChatClient.SendImageAsync(path, cancellationToken, session)
: await WechatChatClient.SendFileAsync(path, cancellationToken, session);
WriteJson(ToMessageOutput(message, includeContent: false));
return 0;
}
case "chat" when args[1] == "read":
{
var includeContent = HasOption(args, "--include-content");
var limit = GetPositiveIntOption(args, "--limit", 20, 100);
var messages = await WechatChatClient.ReadVisibleAsync(cancellationToken);
WriteJson(new { messages = messages.TakeLast(limit).Select(message => ToMessageOutput(message, includeContent)) });
return 0;
}
case "chat" when args[1] == "history":
{
var includeContent = HasOption(args, "--include-content");
var limit = GetPositiveIntOption(args, "--limit", 100, 1000);
var scrolls = GetPositiveIntOption(args, "--scrolls", 10, 100, allowZero: true);
var messages = await WechatChatClient.ReadHistoryAsync(limit, scrolls, cancellationToken,
GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant);
WriteJson(new { messages = messages.Select(message => ToMessageOutput(message, includeContent)) });
return 0;
}
case "chat" when args[1] == "latest":
{
var messages = await WechatChatClient.GoToLatestAsync(GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant,
GetPositiveIntOption(args, "--scrolls", 100, 100), cancellationToken);
WriteJson(new { messages = messages.Select(message => ToMessageOutput(message, HasOption(args, "--include-content"))) });
return 0;
}
case "chat" when args[1] == "merged-open":
{
var windowHandle = await WechatChatClient.OpenMergedChatAsync(GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant,
GetRequiredOption(args, "--fingerprint"), cancellationToken);
WriteJson(new { windowHandle });
return 0;
}
case "chat" when args[1] == "locate":
{
var result = await WechatChatClient.LocateMessageAsync(GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant,
GetRequiredOption(args, "--fingerprint"), GetPositiveIntOption(args, "--scrolls", 30, 100), cancellationToken);
WriteJson(new { result.Scrolls, message = ToMessageOutput(result.Message, HasOption(args, "--include-content")) });
return 0;
}
case "chat" when args[1] == "monitor":
{
var includeContent = HasOption(args, "--include-content");
var seconds = GetPositiveIntOption(args, "--seconds", 60, 86400);
var stateFile = GetOption(args, "--state-file");
await foreach (var messageEvent in WechatChatClient.ListenEventsAsync(
TimeSpan.FromSeconds(seconds),
stateFile,
cancellationToken,
session: GetOption(args, "--session") ?? WechatLocators.FileTransferAssistant,
independentWindow: HasOption(args, "--independent")))
{
Console.WriteLine(JsonSerializer.Serialize(ToEventOutput(messageEvent, includeContent), jsonLineOptions));
}
return 0;
}
case "chat" when args[1] == "listen":
{
var includeContent = HasOption(args, "--include-content");
var seconds = GetPositiveIntOption(args, "--seconds", 30, 300);
var messages = new List<ChatMessageSnapshot>();
await foreach (var message in WechatChatClient.ListenAsync(TimeSpan.FromSeconds(seconds), cancellationToken))
{
messages.Add(message);
}
WriteJson(new { durationSeconds = seconds, messages = messages.Select(message => ToMessageOutput(message, includeContent)) });
return 0;
}
case "session" when args[1] == "list":
{
WriteJson(new { sessions = await WechatChatClient.ListVisibleSessionsAsync(cancellationToken) });
return 0;
}
case "session" when args[1] == "scroll":
{
var direction = GetRequiredOption(args, "--direction") switch
{
"up" => WechatScrollDirection.Up,
"down" => WechatScrollDirection.Down,
_ => throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Direction must be up or down.")
};
var result = await WechatChatClient.ScrollSessionsAsync(direction, GetPositiveIntOption(args, "--pages", 1, 100), cancellationToken);
WriteJson(new { result.Scrolls, result.ViewportChanged, sessions = result.Sessions.Select(session => new { name = HasOption(args, "--include-content") ? session.Name : null, nameId = MaskIdentifier(session.Name), session.IsCurrent }) });
return 0;
}
case "session" when args[1] == "search":
{
var query = GetRequiredOption(args, "--query");
var results = await WechatChatClient.SearchSessionsAsync(query, HasOption(args, "--exact"), cancellationToken, GetOption(args, "--ui-output"));
WriteJson(new { query, results });
return 0;
}
case "session" when args[1] == "current":
{
WriteJson(new { session = await WechatChatClient.GetCurrentSessionAsync(cancellationToken) });
return 0;
}
case "session" when args[1] == "open":
{
WriteJson(await WechatChatClient.OpenSessionAsync(GetRequiredOption(args, "--name"), cancellationToken, GetOption(args, "--query")));
return 0;
}
case "db" when args[1] == "scan":
{
var dataRoot = GetOption(args, "--data-root");
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)
{
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
{
scanned,
processCount,
candidateCount,
verificationAttempts,
verifiedDatabaseCount = finalAccounts.Sum(account => account.Databases.Count),
savedTo,
accounts = finalAccounts.Select(account => new
{
account.AccountRootFingerprint,
account.WechatVersion,
databases = account.Databases.Select(database => new
{
database.RelativePath,
database.SourceProcessId,
database.Confidence,
database.VerifiedAt
})
})
});
return finalAccounts.Any(account => account.Databases.Count > 0) ? 0 : 3;
}
case "db" when args[1] == "status":
{
var keyFile = GetOption(args, "--key-file");
var accounts = await DatabaseKeyStore.LoadAsync(keyFile, cancellationToken);
WriteJson(new
{
keyFile = Path.GetFullPath(keyFile ?? DatabaseKeyStore.DefaultPath),
accounts = accounts.Select(account => new
{
account.AccountRootFingerprint,
account.WechatVersion,
databaseCount = account.Databases.Count,
databases = account.Databases.Select(database => new { database.RelativePath, database.Confidence, database.VerifiedAt })
})
});
return 0;
}
case "db" when args[1] == "query":
{
var accountId = GetRequiredOption(args, "--account");
var relativePath = GetRequiredOption(args, "--database").Replace('\\', '/');
var keyFile = GetOption(args, "--key-file");
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 accountRoot = Path.GetFullPath(account.AccountRootPath) + Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(accountRoot, StringComparison.OrdinalIgnoreCase))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Database path escapes the account root.");
}
var metadata = await SqlCipherDatabaseReader.ReadMetadataAsync(fullPath, database.EncKey, cancellationToken);
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] == "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");
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 limit = GetPositiveIntOption(args, "--limit", 200, 10000);
var offset = GetPositiveIntOption(args, "--offset", 0, int.MaxValue - limit, allowZero: true);
var account = await WechatContactDbReader.LoadAccountAsync(accountId, keyFile, cancellationToken);
var page = await WechatContactDbReader.ReadPageAsync(account, limit, offset, contains,
cancellationToken: cancellationToken);
WriteJson(new
{
count = page.Contacts.Count,
page.HasMore,
page.NextOffset,
contacts = page.Contacts.Select(contact => new
{
username = includeContent ? contact.Username : MaskIdentifier(contact.Username),
nickName = includeContent ? contact.NickName : null,
remark = includeContent ? contact.Remark : null,
avatarUrl = includeContent ? contact.AvatarUrl : null
})
});
return 0;
}
case "db" when args[1] == "group-members":
{
var accountId = GetRequiredOption(args, "--account");
var group = GetRequiredOption(args, "--group");
var includeContent = HasOption(args, "--include-content");
var limit = GetPositiveIntOption(args, "--limit", 500, 10000);
var offset = GetPositiveIntOption(args, "--offset", 0, int.MaxValue - limit, allowZero: true);
var account = await WechatContactDbReader.LoadAccountAsync(accountId, GetOption(args, "--key-file"), cancellationToken);
var page = await WechatContactDbReader.ReadGroupMembersPageAsync(account, group, limit, offset, cancellationToken);
WriteJson(new
{
group = includeContent ? page.GroupUsername : MaskIdentifier(page.GroupUsername),
count = page.Members.Count,
page.HasMore,
page.NextOffset,
members = page.Members.Select(member => new
{
username = includeContent ? member.Username : MaskIdentifier(member.Username),
displayName = includeContent ? member.DisplayName : null,
member.IsOwner
})
});
return 0;
}
default:
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help.");
}
}
catch (OperationCanceledException) when (!shutdown.IsCancellationRequested)
{
RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Warning, "WxAgent.Host", "Host command timed out.");
WriteJson(new { error = WxAgentErrorCode.Timeout.ToString(), message = "Operation timed out." });
return 124;
}
catch (OperationCanceledException)
{
RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Information, "WxAgent.Host", "Host command cancelled.");
WriteJson(new { error = WxAgentErrorCode.OperationCancelled.ToString(), message = "Operation cancelled." });
return 130;
}
catch (WxAgentException exception)
{
RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Error, "WxAgent.Host", "Host command failed.", exception);
WriteJson(new { error = exception.Code.ToString(), exception.Message });
return 1;
}
catch (Exception exception)
{
RuntimeLog.Append(GetRuntimeLogPath(args), Microsoft.Extensions.Logging.LogLevel.Error, "WxAgent.Host", "Host command failed.", exception);
WriteJson(new { error = WxAgentErrorCode.InvalidOperationState.ToString(), message = exception.Message });
return 1;
}
void WriteJson<T>(T value) => Console.WriteLine(JsonSerializer.Serialize(value, jsonOptions));
static string GetRuntimeLogPath(string[] values)
{
try
{
var config = GetOption(values, "--config");
var directory = config is null ? AppContext.BaseDirectory : Path.GetDirectoryName(Path.GetFullPath(config)) ?? AppContext.BaseDirectory;
return Path.Combine(directory, "wxagent.log");
}
catch
{
return Path.Combine(AppContext.BaseDirectory, "wxagent.log");
}
}
static int ValidateCommandLine(string[] values)
{
if (values[0] == "remote")
return 60;
if (values[0] == "doctor")
{
ValidateOptions(values, 1, ["--timeout"], []);
return 30;
}
if (values[0] == "listener-smoke")
{
ValidateOptions(values, 1, ["--output", "--session", "--timeout"], ["--independent"]);
return 60;
}
if (values[0] == "diagnose")
{
ValidateOptions(values, 1, ["--output", "--baseline", "--timeout"], []);
return 60;
}
if (values[0] == "inspect-ui")
{
ValidateOptions(values, 1, ["--output", "--window-title", "--timeout"], []);
return 30;
}
if (values[0] == "smoke")
{
ValidateOptions(values, 1, ["--output", "--timeout"], ["--read-only"]);
return 30;
}
if (values[0] is "recover-ui" or "window-status" or "tray-status" or "recovery-smoke")
{
ValidateOptions(values, 1, ["--timeout"], []);
return 30;
}
if (values[0] == "stability-smoke")
{
ValidateOptions(values, 1, ["--seconds", "--sample-seconds", "--output", "--timeout"], []);
return GetPositiveIntOption(values, "--seconds", 15, 300) + 60;
}
if (values[0] == "endurance")
{
ValidateOptions(values, 1, ["--hours", "--sample-seconds", "--output", "--timeout"], []);
return checked(GetPositiveIntOption(values, "--hours", 24, 72) * 3600 + 120);
}
if (values[0] == "m5-smoke")
{
ValidateOptions(values, 1, ["--timeout"], []);
return 60;
}
if (values[0] == "m4-smoke")
{
ValidateOptions(values, 1, ["--timeout"], []);
return 60;
}
if (values[0] == "group" && values.Length >= 2 && values[1] == "at-all")
{
ValidateOptions(values, 2, ["--group", "--message", "--confirm", "--timeout"], []);
return 30;
}
if (values[0] == "group" && values.Length >= 2 && values[1] == "verify-at-all")
{
ValidateOptions(values, 2, ["--group", "--message", "--timeout"], []);
return 30;
}
if (values[0] == "chat" && values.Length >= 2)
{
switch (values[1])
{
case "send-url-card":
ValidateOptions(values, 2, ["--to", "--url", "--message", "--timeout"], []);
return 60;
case "send-audio":
ValidateOptions(values, 2, ["--to", "--path", "--confirm", "--timeout"], []);
return 60;
case "send":
ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []);
return 30;
case "reply-latest":
ValidateOptions(values, 2, ["--text", "--session", "--timeout"], []);
return 60;
case "send-file":
case "send-image":
ValidateOptions(values, 2, ["--path", "--session", "--timeout"], []);
return 60;
case "mention":
ValidateOptions(values, 2, ["--session", "--member", "--text", "--confirm", "--timeout"], []);
return 60;
case "read":
ValidateOptions(values, 2, ["--limit", "--timeout"], ["--include-content"]);
return 30;
case "history":
ValidateOptions(values, 2, ["--session", "--limit", "--scrolls", "--timeout"], ["--include-content"]);
return 60;
case "latest":
ValidateOptions(values, 2, ["--session", "--scrolls", "--timeout"], ["--include-content"]);
return 60;
case "merged-open":
ValidateOptions(values, 2, ["--session", "--fingerprint", "--timeout"], []);
return 60;
case "locate":
ValidateOptions(values, 2, ["--session", "--fingerprint", "--scrolls", "--timeout"], ["--include-content"]);
return 60;
case "monitor":
ValidateOptions(values, 2, ["--seconds", "--state-file", "--session", "--timeout"], ["--include-content", "--independent"]);
return GetPositiveIntOption(values, "--seconds", 60, 86400) + 30;
case "listen":
ValidateOptions(values, 2, ["--seconds", "--timeout"], ["--include-content"]);
return GetPositiveIntOption(values, "--seconds", 30, 300) + 10;
default:
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown chat command. Run WxAgent.Host help.");
}
}
if (values[0] == "session" && values.Length >= 2)
{
switch (values[1])
{
case "list":
case "current":
ValidateOptions(values, 2, ["--timeout"], []);
return 30;
case "scroll":
ValidateOptions(values, 2, ["--direction", "--pages", "--timeout"], ["--include-content"]);
return 60;
case "search":
ValidateOptions(values, 2, ["--query", "--ui-output", "--timeout"], ["--exact"]);
return 30;
case "open":
ValidateOptions(values, 2, ["--name", "--query", "--timeout"], []);
return 30;
default:
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown session command. Run WxAgent.Host help.");
}
}
if (values[0] != "db" || values.Length < 2)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help.");
}
switch (values[1])
{
case "scan":
ValidateOptions(values, 2, ["--data-root", "--key-file", "--timeout"], ["--save"]);
return 120;
case "status":
ValidateOptions(values, 2, ["--key-file", "--timeout"], []);
return 30;
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;
case "contacts":
ValidateOptions(values, 2, ["--account", "--key-file", "--contains", "--limit", "--offset", "--timeout"], ["--include-content"]);
return 60;
case "group-members":
ValidateOptions(values, 2, ["--account", "--group", "--key-file", "--limit", "--offset", "--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.");
}
}
static void ValidateOptions(string[] values, int startIndex, HashSet<string> valueOptions, HashSet<string> flagOptions)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
for (var index = startIndex; index < values.Length; index++)
{
var option = values[index];
if (!seen.Add(option))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Duplicate option {option}.");
}
if (flagOptions.Contains(option))
{
continue;
}
if (!valueOptions.Contains(option))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Unknown argument {option}.");
}
if (++index >= values.Length || values[index].StartsWith("--", StringComparison.Ordinal))
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"Missing value for {option}.");
}
}
}
static string? GetOption(string[] values, string name)
{
var index = Array.IndexOf(values, name);
return index >= 0 && index + 1 < values.Length && !values[index + 1].StartsWith("--", StringComparison.Ordinal)
? values[index + 1]
: null;
}
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
: "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)
{
var raw = GetOption(values, "--timeout");
if (raw is null)
{
return fallback;
}
if (!int.TryParse(raw, out var parsed) || parsed <= 0)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "--timeout must be a positive whole number of seconds.");
}
return parsed;
}
static int GetPositiveIntOption(string[] values, string name, int fallback, int maximum, bool allowZero = false)
{
var raw = GetOption(values, name);
if (raw is null)
{
return fallback;
}
var minimum = allowZero ? 0 : 1;
if (!int.TryParse(raw, out var parsed) || parsed < minimum || parsed > maximum)
{
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, $"{name} must be between {minimum} and {maximum}.");
}
return parsed;
}
static bool IsImagePath(string path) => new[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp" }
.Contains(Path.GetExtension(path), StringComparer.OrdinalIgnoreCase);
static object ToEventOutput(MessageEvent messageEvent, bool includeContent) => new
{
messageEvent.EventId,
messageEvent.Kind,
messageEvent.Session,
messageEvent.ObservedAt,
messageEvent.Recovered,
message = messageEvent.Message is null ? null : ToMessageOutput(messageEvent.Message, includeContent)
};
static object ToMessageOutput(ChatMessageSnapshot message, bool includeContent) => new
{
message.Fingerprint,
message.Type,
length = message.Text.Length,
content = includeContent ? message.Text : null,
quote = includeContent ? message.Quote : null
};
static int CountNodes(UiNodeSnapshot node) => 1 + node.Children.Sum(CountNodes);
static void PrintHelp() => Console.WriteLine("""
WxAgent.Host commands:
serve --config <service.json>
remote auth show|set|clear --config <remote.json>
auth set options: --address <url> --token <token> | --token-file <path> --node <id>
[--server-ca-file <pem>] [--client-certificate-file <pem> --client-certificate-key-file <pem>]
remote status show --config <remote.json> [--data-dir <dir>]
remote probe run --config <remote.json> [--timeout 60]
remote reporting show|enable|disable|account-add|account-enable|account-disable|allow|deny --config <remote.json>
doctor [--timeout 30]
diagnose --output <dir> [--baseline <ui-tree.json>] [--timeout 60]
inspect-ui --output <path> [--window-title <title>] [--timeout 30]
smoke [--output <path>] [--read-only] [--timeout 30]
m4-smoke [--timeout 60]
m5-smoke [--timeout 60]
tray-status [--timeout 30]
window-status [--timeout 30]
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>]
endurance [--hours 24] [--sample-seconds 60] [--output <dir>]
group at-all --group <name> --message <text> --confirm CONFIRM [--timeout 30]
group verify-at-all --group <name> --message <text> [--timeout 30]
chat send-url-card --to <name> --url <http-url> [--message <text>] [--timeout 60]
chat send-audio --to <name> --path <audio-file> --confirm CONFIRM [--timeout 60]
chat send --text <text-up-to-4000-chars; multiline-supported> [--session <name>] [--timeout 30]
chat reply-latest --text <text-up-to-4000-chars; multiline-supported> [--session <name>] [--timeout 60]
chat send-file --path <file> [--session <name>] [--timeout 60]
chat send-image --path <image> [--session <name>] [--timeout 60]
chat mention --session <name> --member <display-name> --text <text> --confirm CONFIRM [--timeout 60]
chat read [--limit 20] [--include-content] [--timeout 30]
chat history [--session <name>] [--limit 100] [--scrolls 10] [--include-content] [--timeout 60]
chat latest [--session <name>] [--scrolls 100] [--include-content] [--timeout 60]
chat merged-open --fingerprint <visible-message-fingerprint> [--session <name>] [--timeout 60]
chat locate --fingerprint <visible-message-fingerprint> [--session <name>] [--scrolls 30] [--include-content] [--timeout 60]
chat monitor [--seconds 60] [--session <name>] [--independent] [--state-file <path>] [--include-content] [--timeout <seconds>]
chat listen [--seconds 30] [--include-content] [--timeout <seconds>]
session list [--timeout 30]
session scroll --direction <up|down> [--pages 1] [--include-content] [--timeout 60]
session search --query <text> [--exact] [--ui-output <path>] [--timeout 30]
session current [--timeout 30]
session open --name <session-name> [--query <keyword>] [--timeout 30]
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 merged --account <fingerprint> --chat <wxid> --local-id <id> [--database <relative-path>] [--include-content] [--key-file <path>] [--timeout 60]
db contacts --account <fingerprint> [--contains <literal-text>] [--limit 200] [--offset 0] [--include-content] [--key-file <path>] [--timeout 60]
db group-members --account <fingerprint> --group <name-or-@chatroom-id> [--limit 500] [--offset 0] [--include-content] [--key-file <path>] [--timeout 60]
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. Already verified accounts are skipped by the key cache.
""");