using System.Text.Json; using System.Text.Json.Serialization; using WxAgent.Core; using WxAgent.Windows; 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; } 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 "doctor": { var report = WechatDoctor.Run(cancellationToken); WriteJson(report); return report.Errors.Count == 0 ? 0 : 2; } case "inspect-ui": { var output = GetRequiredOption(args, "--output"); var snapshot = await WechatUiInspector.CaptureAsync(output, cancellationToken); 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 marker = $"wxagent-smoke-{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"; var confirmed = await WechatChatClient.SendTextAsync(marker, cancellationToken); WriteJson(new { scope = "M1", doctorErrors = report.Errors, output = Path.GetFullPath(output), nodes = CountNodes(snapshot), sanitized = true, sentTo = "file-transfer-assistant", confirmed.Fingerprint, confirmed = true }); return report.Errors.Count == 0 ? 0 : 2; } case "chat" when args[1] == "send": { var message = await WechatChatClient.SendTextAsync(GetRequiredOption(args, "--text"), cancellationToken); WriteJson(ToMessageOutput(message, includeContent: false)); return 0; } case "chat" when args[1] == "reply-latest": { var message = await WechatChatClient.ReplyToLatestAsync(GetRequiredOption(args, "--text"), cancellationToken); WriteJson(ToMessageOutput(message, includeContent: false)); return 0; } case "chat" when args[1] is "send-file" or "send-image": { var path = GetRequiredOption(args, "--path"); 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) : await WechatChatClient.SendFileAsync(path, cancellationToken); 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); WriteJson(new { messages = messages.Select(message => ToMessageOutput(message, includeContent)) }); 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)) { 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] == "search": { var query = GetRequiredOption(args, "--query"); var results = await WechatChatClient.SearchSessionsAsync(query, HasOption(args, "--exact"), cancellationToken); 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)); return 0; } case "db" when args[1] == "scan": { var dataRoot = GetOption(args, "--data-root"); var result = WechatDatabaseScanner.Scan(dataRoot, cancellationToken); string? savedTo = null; if (HasOption(args, "--save")) { savedTo = GetOption(args, "--key-file") ?? DatabaseKeyStore.DefaultPath; var existing = await DatabaseKeyStore.LoadAsync(savedTo, cancellationToken); var merged = AccountKeySetMerge.Merge(existing, result.Accounts); await DatabaseKeyStore.SaveAsync(merged, savedTo, cancellationToken); } WriteJson(new { result.ProcessCount, result.CandidateCount, result.DatabaseCount, verifiedDatabaseCount = result.Accounts.Sum(account => account.Databases.Count), savedTo, accounts = result.Accounts.Select(account => new { account.AccountRootFingerprint, account.WechatVersion, databases = account.Databases.Select(database => new { database.RelativePath, database.SourceProcessId, database.Confidence, database.VerifiedAt }) }) }); return result.Accounts.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; } default: throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Unknown command. Run WxAgent.Host help."); } } catch (OperationCanceledException) when (!shutdown.IsCancellationRequested) { WriteJson(new { error = WxAgentErrorCode.Timeout.ToString(), message = "Operation timed out." }); return 124; } catch (OperationCanceledException) { WriteJson(new { error = WxAgentErrorCode.OperationCancelled.ToString(), message = "Operation cancelled." }); return 130; } catch (WxAgentException exception) { WriteJson(new { error = exception.Code.ToString(), exception.Message }); return 1; } catch (Exception exception) { WriteJson(new { error = WxAgentErrorCode.InvalidOperationState.ToString(), message = exception.Message }); return 1; } void WriteJson(T value) => Console.WriteLine(JsonSerializer.Serialize(value, jsonOptions)); static int ValidateCommandLine(string[] values) { if (values[0] == "doctor") { ValidateOptions(values, 1, ["--timeout"], []); return 30; } if (values[0] == "inspect-ui") { ValidateOptions(values, 1, ["--output", "--timeout"], []); return 30; } if (values[0] == "smoke") { ValidateOptions(values, 1, ["--output", "--timeout"], []); return 30; } if (values[0] == "chat" && values.Length >= 2) { switch (values[1]) { case "send": ValidateOptions(values, 2, ["--text", "--timeout"], []); return 30; case "reply-latest": ValidateOptions(values, 2, ["--text", "--timeout"], []); return 60; case "send-file": case "send-image": ValidateOptions(values, 2, ["--path", "--timeout"], []); return 60; case "read": ValidateOptions(values, 2, ["--limit", "--timeout"], ["--include-content"]); return 30; case "history": ValidateOptions(values, 2, ["--limit", "--scrolls", "--timeout"], ["--include-content"]); return 60; case "monitor": ValidateOptions(values, 2, ["--seconds", "--state-file", "--timeout"], ["--include-content"]); 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 "search": ValidateOptions(values, 2, ["--query", "--timeout"], ["--exact"]); return 30; case "open": ValidateOptions(values, 2, ["--name", "--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; 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 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: doctor [--timeout 30] inspect-ui --output [--timeout 30] smoke [--output ] [--timeout 30] chat send --text [--timeout 30] chat reply-latest --text [--timeout 60] chat send-file --path [--timeout 60] chat send-image --path [--timeout 60] chat read [--limit 20] [--include-content] [--timeout 30] chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60] chat monitor [--seconds 60] [--state-file ] [--include-content] [--timeout ] chat listen [--seconds 30] [--include-content] [--timeout ] session list [--timeout 30] session search --query [--exact] [--timeout 30] session current [--timeout 30] session open --name [--timeout 30] db scan [--data-root ] [--save] [--key-file ] [--timeout 120] db status [--key-file ] [--timeout 30] db query --account --database [--key-file ] [--timeout 30] Chat commands are restricted to File Transfer Assistant. Message content is omitted unless --include-content is explicit. Database keys are never printed. db scan only saves verified keys when --save is present. """);