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") throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Windows Agent must be started by double-clicking WxAgent.Tray.exe; configuration is GUI-only."); 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(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(); 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() : 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 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 valueOptions, HashSet flagOptions) { var seen = new HashSet(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 (diagnostics/read-only only; configuration is GUI-only): remote auth show --config remote status show --config [--data-dir ] remote probe run --config [--timeout 60] remote reporting show --config WxAgent.Tray.exe must be started by double-click; use its 服务设置... window for configuration. doctor [--timeout 30] diagnose --output [--baseline ] [--timeout 60] inspect-ui --output [--window-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. """);