using System.Net.Http; using WxAgent.Core; namespace WxAgent.Host; internal static class RemoteCliCommands { public static async Task RunAsync(string[] args, CancellationToken cancellationToken) { if (args.Length < 3) throw Invalid("Remote commands require a group and action."); if (IsConfigurationCommand(args)) throw Invalid("Windows Agent configuration is GUI-only; double-click WxAgent.Tray.exe and use 服务设置... ."); var configPath = Path.GetFullPath(Required(args, "--config")); var group = args[1]; var action = args[2]; return (group, action) switch { ("auth", "show") => await AuthShowAsync(configPath, cancellationToken), ("auth", "set") => await AuthSetAsync(args, configPath, cancellationToken), ("auth", "clear") => await AuthClearAsync(configPath, cancellationToken), ("probe", "run") => await ProbeAsync(configPath, cancellationToken), ("status", "show") => await StatusShowAsync(args, configPath, cancellationToken), ("reporting", "show") => await ReportingShowAsync(configPath, cancellationToken), ("reporting", "enable") => await ReportingToggleAsync(configPath, true, cancellationToken), ("reporting", "disable") => await ReportingToggleAsync(configPath, false, cancellationToken), ("reporting", "account-add") => await AccountToggleAsync(args, configPath, false, cancellationToken), ("reporting", "account-enable") => await AccountToggleAsync(args, configPath, true, cancellationToken), ("reporting", "account-disable") => await AccountToggleAsync(args, configPath, false, cancellationToken), ("reporting", "allow") => await AllowChatAsync(args, configPath, cancellationToken), ("reporting", "deny") => await DenyChatAsync(args, configPath, cancellationToken), _ => throw Invalid("Unknown remote command. Use: auth show|set|clear, status show, probe run, reporting show|enable|disable|account-add|account-enable|account-disable|allow|deny.") }; } private static async Task AuthShowAsync(string path, CancellationToken cancellationToken) { var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); return new { config = path, remote = configuration.Remote.Redacted(), configured = configuration.Remote.IsConfigured, reportingConfigVersion = configuration.Reporting.ConfigVersion }; } private static async Task AuthSetAsync(string[] args, string path, CancellationToken cancellationToken) { var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); var remote = new RemoteAgentOptions { AuthAddress = Required(args, "--address"), Token = Option(args, "--token"), TokenFile = Option(args, "--token-file"), ServerCaFile = Option(args, "--server-ca-file"), ClientCertificateFile = Option(args, "--client-certificate-file"), ClientCertificateKeyFile = Option(args, "--client-certificate-key-file"), NodeId = Required(args, "--node"), ActiveAccountId = Option(args, "--active-account"), AllowInsecureHttp = Has(args, "--allow-insecure-http") }; remote.Validate(); var next = (current with { Remote = remote }).WithAudit("remote.auth.set"); await RemoteNodeConfigurationStore.SaveAsync(path, next, cancellationToken); return new { saved = path, remote = remote.Redacted() }; } private static async Task AuthClearAsync(string path, CancellationToken cancellationToken) { var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Remote = new RemoteAgentOptions() }).WithAudit("remote.auth.clear"), cancellationToken); return new { saved = path, configured = false }; } private static async Task ProbeAsync(string path, CancellationToken cancellationToken) { var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); configuration.Remote.Validate(); using var client = new RemoteControlClient(configuration.Remote); var accounts = configuration.Reporting.Accounts.Select(account => new RemoteAccountSummary( account.AccountId, string.Equals(account.AccountId, configuration.Remote.ActiveAccountId, StringComparison.Ordinal), account.Enabled, account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Group && chat.Enabled && chat.IdentityVerified), account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Private && chat.Enabled && chat.IdentityVerified))).ToArray(); var registration = await client.RegisterAsync(new RemoteNodeRegistration(configuration.Remote.NodeId!, "cli", RemoteProtocol.Version, ["heartbeat", "poll-tasks", "send-text", "report-message"], configuration.Reporting.ConfigVersion, accounts), cancellationToken); var heartbeat = await client.HeartbeatAsync(new RemoteHeartbeat(configuration.Remote.NodeId!, "cli", RemoteProtocol.Version, RemoteNodeStatus.Online, false, false, false, configuration.Remote.ActiveAccountId, 0, configuration.Reporting.ConfigVersion, Guid.NewGuid().ToString("N")), cancellationToken); return new { status = "ok", node = registration.NodeId, authState = client.AuthState, heartbeat = heartbeat.Status }; } private static async Task StatusShowAsync(string[] args, string path, CancellationToken cancellationToken) { var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); var dataDirectory = Path.GetFullPath(Option(args, "--data-dir") ?? Path.GetDirectoryName(path) ?? AppContext.BaseDirectory); var eventQueue = new RemoteEventQueue(Path.Combine(dataDirectory, "remote-event-queue.json")); var ledger = new RemoteTaskLedger(Path.Combine(dataDirectory, "remote-task-ledger.json")); return new { config = path, remote = configuration.Remote.Redacted(), configured = configuration.Remote.IsConfigured, reporting = new { configuration.Reporting.Enabled, configuration.Reporting.ConfigVersion, pendingEvents = eventQueue.PendingCount }, unreportedTaskResults = ledger.UnreportedResults().Count, audit = configuration.Audit.TakeLast(20) }; } private static async Task ReportingShowAsync(string path, CancellationToken cancellationToken) { var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); return new { config = path, enabled = configuration.Reporting.Enabled, configVersion = configuration.Reporting.ConfigVersion, accounts = configuration.Reporting.Accounts.Select(account => new { accountId = Mask(account.AccountId), account.Enabled, allowedGroupCount = account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Group && chat.Enabled && chat.IdentityVerified), allowedPrivateCount = account.AllowedChats.Count(chat => chat.Type == ReportingChatType.Private && chat.Enabled && chat.IdentityVerified), unverifiedCount = account.AllowedChats.Count(chat => !chat.IdentityVerified) }), audit = configuration.Audit.TakeLast(20) }; } private static async Task ReportingToggleAsync(string path, bool enabled, CancellationToken cancellationToken) { var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Enabled = enabled }); await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit(enabled ? "reporting.enable" : "reporting.disable"), cancellationToken); return new { saved = path, reporting.Enabled, reporting.ConfigVersion }; } private static async Task AccountToggleAsync(string[] args, string path, bool enabled, CancellationToken cancellationToken) { var accountId = Required(args, "--account"); var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); var accounts = current.Reporting.Accounts.ToList(); var index = accounts.FindIndex(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal)); if (index < 0) { if (args[2] != "account-add") throw Invalid("The account does not exist; run reporting account-add first."); accounts.Add(new AccountReportingConfig { AccountId = accountId, Enabled = enabled }); } else { accounts[index] = accounts[index] with { Enabled = enabled }; } var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Accounts = accounts }); await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit($"reporting.account.{(enabled ? "enable" : "disable")}"), cancellationToken); return new { saved = path, accountId = Mask(accountId), enabled, reporting.ConfigVersion }; } private static async Task AllowChatAsync(string[] args, string path, CancellationToken cancellationToken) { if (!Has(args, "--identity-verified")) throw Invalid("Allowing a chat requires --identity-verified after the stable identity was confirmed."); var accountId = Required(args, "--account"); var chatId = Required(args, "--chat-id"); var type = ParseChatType(Required(args, "--type")); var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); var accounts = current.Reporting.Accounts.ToList(); var index = accounts.FindIndex(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal)); if (index < 0 || !accounts[index].Enabled) throw Invalid("The account must exist and be enabled before a chat can be allowed."); var chats = accounts[index].AllowedChats.Where(chat => chat.Type != type || chat.ChatId != chatId).ToList(); chats.Add(new AllowedChat { Type = type, ChatId = chatId, Enabled = true, IdentityVerified = true }); accounts[index] = accounts[index] with { AllowedChats = chats }; var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Accounts = accounts }); await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit("reporting.chat.allow"), cancellationToken); return new { saved = path, accountId = Mask(accountId), chatId = Mask(chatId), type, reporting.ConfigVersion }; } private static async Task DenyChatAsync(string[] args, string path, CancellationToken cancellationToken) { var accountId = Required(args, "--account"); var chatId = Required(args, "--chat-id"); var type = ParseChatType(Required(args, "--type")); var current = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken); var accounts = current.Reporting.Accounts.ToList(); var index = accounts.FindIndex(account => string.Equals(account.AccountId, accountId, StringComparison.Ordinal)); if (index < 0) throw Invalid("The account does not exist."); accounts[index] = accounts[index] with { AllowedChats = accounts[index].AllowedChats.Where(chat => chat.Type != type || chat.ChatId != chatId).ToArray() }; var reporting = ReportingConfigStore.Update(current.Reporting, value => value with { Accounts = accounts }); await RemoteNodeConfigurationStore.SaveAsync(path, (current with { Reporting = reporting }).WithAudit("reporting.chat.deny"), cancellationToken); return new { saved = path, accountId = Mask(accountId), chatId = Mask(chatId), type, reporting.ConfigVersion }; } private static bool IsConfigurationCommand(string[] args) => (args[1], args[2]) switch { ("auth", "set" or "clear") => true, ("reporting", "enable" or "disable" or "account-add" or "account-enable" or "account-disable" or "allow" or "deny") => true, _ => false }; private static ReportingChatType ParseChatType(string value) => value.ToLowerInvariant() switch { "group" => ReportingChatType.Group, "private" => ReportingChatType.Private, _ => throw Invalid("--type must be group or private.") }; private static string Required(string[] args, string name) => Option(args, name) switch { { Length: > 0 } value => value, _ => throw Invalid($"Missing {name}.") }; private static string? Option(string[] args, string name) { var index = Array.IndexOf(args, name); return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; } private static bool Has(string[] args, string name) => args.Contains(name, StringComparer.Ordinal); private static string Mask(string value) => value.Length <= 8 ? "" : value[..4] + "…" + value[^4..]; private static WxAgentException Invalid(string message) => new(WxAgentErrorCode.InvalidArgument, message); }