Files
wx-win-agent/src/WxAgent.Host/Program.cs
T
2026-09-04 21:46:38 +08:00

470 lines
19 KiB
C#

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<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] == "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>(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<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 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 <path> [--timeout 30]
smoke [--output <path>] [--timeout 30]
chat send --text <one-line-text> [--timeout 30]
chat reply-latest --text <one-line-text> [--timeout 60]
chat send-file --path <file> [--timeout 60]
chat send-image --path <image> [--timeout 60]
chat read [--limit 20] [--include-content] [--timeout 30]
chat history [--limit 100] [--scrolls 10] [--include-content] [--timeout 60]
chat monitor [--seconds 60] [--state-file <path>] [--include-content] [--timeout <seconds>]
chat listen [--seconds 30] [--include-content] [--timeout <seconds>]
session list [--timeout 30]
session search --query <text> [--exact] [--timeout 30]
session current [--timeout 30]
session open --name <session-name> [--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]
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.
""");