1088 lines
52 KiB
C#
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.
|
|
""");
|