Initial import
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
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.
|
||||
""");
|
||||
Reference in New Issue
Block a user