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