refactor(agent): remove obsolete compatibility paths
This commit is contained in:
@@ -14,9 +14,9 @@
|
||||
- `db contacts` 与管理 API 共用读取器;分页 SQL 使用参数绑定、稳定 username 排序及多取一行判断 `hasMore`,不以显示名去重。
|
||||
- CLI 新增 `--limit`(1–10000,默认 200)、`--offset`(默认 0),返回 `hasMore/nextOffset`。`--contains` 是区分大小写的字面量子串;默认输出仍脱敏。
|
||||
- C# 入口 `GetContactsPageAsync` 支持账号、密钥文件和群/非群筛选。未选择账号且密钥缓存包含多个账号时返回 `InvalidArgument`,不会随便选取一个。
|
||||
- `GetFriendsAsync` 保留显式数量上限;全量读取用分页接口。`GetRecentGroupsAsync` 兼容名称仅返回数据库已知群,不承诺最近活动顺序。
|
||||
- `GetFriendsAsync` 保留显式数量上限;全量读取用分页接口。群列表使用 `GetContactsPageAsync` 分页读取,不承诺最近活动顺序。
|
||||
- 联系人详情只映射已知字段;未知标签、签名、来源及共同群数量保持 null。非群联系人数据不能用于证明好友关系。
|
||||
- 群成员使用 `chat_room`、`chatroom_member`、`contact` 的只读 join,`GetGroupMembersAsync` 支持稳定 username 和分页,不回退 UI 枚举;已用测试群脱敏验证。
|
||||
- 群成员使用 `chat_room`、`chatroom_member`、`contact` 的只读 join,`GetGroupMembersPageAsync` 支持稳定 username 和分页,不回退 UI 枚举;已用测试群脱敏验证。
|
||||
- offset 分页不是跨页事务快照;期间发生增删时仍可能重复/遗漏。
|
||||
|
||||
### URL 卡片与草稿
|
||||
|
||||
@@ -37,26 +37,10 @@ public sealed record RemoteNodeConfiguration
|
||||
Audit = (Audit ?? []).TakeLast(1000).ToArray()
|
||||
};
|
||||
}
|
||||
|
||||
public RemoteNodeConfiguration WithAudit(string action)
|
||||
{
|
||||
RemoteAgentOptions.ValidateIdentifier(action, "action", 120);
|
||||
return this with
|
||||
{
|
||||
Audit = (Audit ?? []).Append(new RemoteConfigurationAuditEntry
|
||||
{
|
||||
At = DateTimeOffset.UtcNow,
|
||||
Action = action,
|
||||
ConfigVersion = Reporting?.ConfigVersion ?? 0
|
||||
}).TakeLast(1000).ToArray()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class RemoteNodeConfigurationStore
|
||||
{
|
||||
private static readonly SemaphoreSlim Gate = new(1, 1);
|
||||
|
||||
public static async Task<RemoteNodeConfiguration> LoadAsync(string path, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
@@ -82,40 +66,4 @@ public static class RemoteNodeConfigurationStore
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task SaveAsync(string path, RemoteNodeConfiguration configuration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var normalized = configuration.NormalizeAndValidate();
|
||||
var destination = Path.GetFullPath(path);
|
||||
var directory = Path.GetDirectoryName(destination) ?? AppContext.BaseDirectory;
|
||||
Directory.CreateDirectory(directory);
|
||||
await Gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var temporary = destination + ".tmp-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, normalized, RemoteJson.Options, cancellationToken);
|
||||
await stream.FlushAsync(cancellationToken);
|
||||
}
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
try { File.SetUnixFileMode(temporary, UnixFileMode.UserRead | UnixFileMode.UserWrite); }
|
||||
catch (PlatformNotSupportedException) { }
|
||||
}
|
||||
if (OperatingSystem.IsWindows() && File.Exists(destination)) File.Replace(temporary, destination, null);
|
||||
else File.Move(temporary, destination, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporary)) File.Delete(temporary);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Gate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ try
|
||||
await WechatChatClient.CloseSubWindowAsync("文件传输助手", cancellationToken);
|
||||
var contacts = await WechatChatClient.GetFriendsAsync(5000, cancellationToken);
|
||||
var newFriends = await WechatChatClient.GetNewFriendsAsync(true, cancellationToken);
|
||||
var groups = await WechatChatClient.GetRecentGroupsAsync(cancellationToken);
|
||||
var groups = await WechatChatClient.GetContactsPageAsync(limit: 200, groupsOnly: true, cancellationToken: cancellationToken);
|
||||
var myInfo = await WechatChatClient.GetMyInfoAsync(cancellationToken);
|
||||
await WechatChatClient.SwitchToChatsAsync(cancellationToken);
|
||||
var confirmationGuarded = false;
|
||||
@@ -236,7 +236,7 @@ try
|
||||
historyCount = history.Count,
|
||||
contactCount = contacts.Count,
|
||||
newFriendCount = newFriends.Count,
|
||||
recentGroupCount = groups.Count,
|
||||
groupPageCount = groups.Contacts.Count,
|
||||
accountDetected = !string.IsNullOrWhiteSpace(myInfo.DisplayName),
|
||||
confirmationGuarded,
|
||||
privacy = "names and account identifiers omitted"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Net.Http;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Host;
|
||||
@@ -17,19 +16,10 @@ internal static class RemoteCliCommands
|
||||
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.")
|
||||
_ => throw Invalid("Unknown remote command. Use: auth show, status show, probe run, reporting show.")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,34 +35,6 @@ internal static class RemoteCliCommands
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> 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<object> 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<object> ProbeAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var configuration = await RemoteNodeConfigurationStore.LoadAsync(path, cancellationToken);
|
||||
@@ -129,72 +91,6 @@ internal static class RemoteCliCommands
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> 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<object> 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<object> 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<object> 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,
|
||||
@@ -202,13 +98,6 @@ internal static class RemoteCliCommands
|
||||
_ => 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,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using WxAgent.Core;
|
||||
|
||||
@@ -20,11 +19,10 @@ public sealed class EventPump(IAgentBackend backend, EventHub hub, ServiceOption
|
||||
{
|
||||
await foreach (var item in source.ListenAsync(stoppingToken))
|
||||
{
|
||||
var runtime = await LoadRuntimeConfigurationAsync(options, stoppingToken);
|
||||
if (remoteQueue is not null && runtime.Remote.IsConfigured && item.Kind == "message"
|
||||
if (remoteQueue is not null && options.Remote is { IsConfigured: true } remote && item.Kind == "message"
|
||||
&& item.ChatId is { Length: > 0 } chatId && item.ChatType is { } chatType)
|
||||
{
|
||||
_ = remoteQueue.Enqueue(runtime.Reporting, runtime.Remote.NodeId!, item.AccountId, chatId, chatType,
|
||||
_ = remoteQueue.Enqueue(options.Reporting, remote.NodeId!, item.AccountId, chatId, chatType,
|
||||
item.Kind, item.At, item.Content);
|
||||
}
|
||||
var localItem = item with { Content = null };
|
||||
@@ -37,18 +35,4 @@ public sealed class EventPump(IAgentBackend backend, EventHub hub, ServiceOption
|
||||
catch (Exception) { await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); }
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<RemoteNodeConfiguration> LoadRuntimeConfigurationAsync(ServiceOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
||||
return new RemoteNodeConfiguration { Remote = options.Remote ?? new RemoteAgentOptions(), Reporting = options.Reporting };
|
||||
try
|
||||
{
|
||||
return await RemoteNodeConfigurationStore.LoadAsync(options.RemoteConfigurationFile, cancellationToken);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException or WxAgentException)
|
||||
{
|
||||
return new RemoteNodeConfiguration();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,31 +19,17 @@ public sealed class RemoteAgentHostedService(
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
RemoteAgentOptions? configuredRemote = options.Remote;
|
||||
if (!string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
configuredRemote = (await RemoteNodeConfigurationStore.LoadAsync(options.RemoteConfigurationFile, stoppingToken)).Remote;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException or WxAgentException)
|
||||
{
|
||||
logger.LogWarning("Remote agent is disabled because the external configuration could not be loaded; type={ExceptionType}.", exception.GetType().Name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (configuredRemote is null || !configuredRemote.IsConfigured)
|
||||
var remote = options.Remote;
|
||||
if (remote is null || !remote.IsConfigured)
|
||||
{
|
||||
logger.LogInformation("Remote agent is disabled because authAddress, token or nodeId is not configured.");
|
||||
return;
|
||||
}
|
||||
var remote = configuredRemote;
|
||||
|
||||
using var client = new RemoteControlClient(remote);
|
||||
var ledger = new RemoteTaskLedger(Path.Combine(options.DataDirectory, "remote-task-ledger.json"));
|
||||
var eventQueue = remoteQueue ?? new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json"));
|
||||
// A configured Agent connection is the data-sync authorization boundary.
|
||||
// EnableDataSync remains only as a read-compatibility property for old service.json files.
|
||||
var dataQueue = new RemoteDataBatchQueue(
|
||||
Path.Combine(options.DataDirectory, "remote-data-queue.json"), options.DataSyncQueueMaxItems, options.DataSyncQueueMaxBytes);
|
||||
var syncState = new RemoteDataSyncStateStore(Path.Combine(options.DataDirectory, "remote-data-sync-state.json"));
|
||||
@@ -52,33 +38,14 @@ public sealed class RemoteAgentHostedService(
|
||||
string? registeredActiveAccountId = null;
|
||||
bool? registeredActiveAccountVerified = null;
|
||||
long registeredReportingConfigVersion = -1;
|
||||
var initialReporting = options.Reporting;
|
||||
if (!string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
||||
{
|
||||
try
|
||||
{
|
||||
initialReporting = (await LoadRuntimeConfigurationAsync(options, stoppingToken)).Reporting;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or JsonException or WxAgentException)
|
||||
{
|
||||
logger.LogWarning("Remote reporting configuration could not be loaded for offline collection; type={ExceptionType}.", exception.GetType().Name);
|
||||
}
|
||||
}
|
||||
ReportingConfig? activeReporting = ConnectionAuthorizedReporting(initialReporting, null);
|
||||
ReportingConfig? activeReporting = ConnectionAuthorizedReporting(options.Reporting, null);
|
||||
var retry = RetryDelay;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var runtime = await LoadRuntimeConfigurationAsync(options, stoppingToken);
|
||||
if (!runtime.Remote.IsConfigured || !runtime.Remote.Equals(remote))
|
||||
{
|
||||
logger.LogWarning("Remote configuration changed or is not valid; remote work is paused until the service is restarted.");
|
||||
await DelayAsync(RetryDelay, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
var snapshot = await ReadSnapshotAsync(remote, stoppingToken);
|
||||
var reporting = ConnectionAuthorizedReporting(runtime.Reporting, snapshot);
|
||||
var reporting = ConnectionAuthorizedReporting(options.Reporting, snapshot);
|
||||
activeReporting = reporting;
|
||||
var registrationNeedsRefresh = client.AuthState != RemoteAuthState.Authenticated
|
||||
|| !string.Equals(registeredActiveAccountId, snapshot.ActiveAccountId, StringComparison.Ordinal)
|
||||
@@ -293,11 +260,6 @@ public sealed class RemoteAgentHostedService(
|
||||
{
|
||||
remote = await client.GetDataSyncStatusAsync(account.AccountId, cancellationToken, streamKey).ConfigureAwait(false);
|
||||
}
|
||||
catch (RemoteClientException exception) when (exception.StatusCode == 404 && exception.Code == "NotFound")
|
||||
{
|
||||
// Older control planes do not expose the reconciliation endpoint; retain the legacy ACK path.
|
||||
continue;
|
||||
}
|
||||
catch (RemoteClientException exception) when (exception.StatusCode == 403 && exception.Code == "AccountNotAuthorized")
|
||||
{
|
||||
var droppedOnRevoke = queue.DropAllForAccount(account.AccountId);
|
||||
@@ -879,13 +841,6 @@ public sealed class RemoteAgentHostedService(
|
||||
JsonElement Content,
|
||||
IReadOnlyList<RemoteReportingScope> ChatScopes);
|
||||
|
||||
private static async Task<RemoteNodeConfiguration> LoadRuntimeConfigurationAsync(ServiceOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
||||
return new RemoteNodeConfiguration { Remote = options.Remote ?? new RemoteAgentOptions(), Reporting = options.Reporting };
|
||||
return await RemoteNodeConfigurationStore.LoadAsync(options.RemoteConfigurationFile, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task ReplayUnreportedResultsAsync(
|
||||
RemoteControlClient client,
|
||||
RemoteTaskLedger ledger,
|
||||
|
||||
@@ -54,10 +54,10 @@ public static class ServiceHost
|
||||
builder.Services.AddSingleton<AccountBindingStore>();
|
||||
builder.Services.AddSingleton<OperationQueue>();
|
||||
builder.Services.AddHostedService(p => p.GetRequiredService<OperationQueue>());
|
||||
if (options.Remote is not null || !string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
||||
if (options.Remote is not null)
|
||||
builder.Services.AddSingleton(new RemoteEventQueue(Path.Combine(options.DataDirectory, "remote-event-queue.json")));
|
||||
builder.Services.AddHostedService<EventPump>();
|
||||
if (options.Remote is not null || !string.IsNullOrWhiteSpace(options.RemoteConfigurationFile))
|
||||
if (options.Remote is not null)
|
||||
builder.Services.AddHostedService<RemoteAgentHostedService>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
builder.Services.AddScoped<AgentService>();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using WxAgent.Core;
|
||||
|
||||
namespace WxAgent.Service;
|
||||
@@ -21,27 +20,16 @@ public sealed class ServiceOptions
|
||||
public required string DataDirectory { get; init; }
|
||||
public RemoteAgentOptions? Remote { get; init; }
|
||||
public ReportingConfig Reporting { get; init; } = new();
|
||||
public string? RemoteConfigurationFile { get; init; }
|
||||
// Connection authorization covers data synchronization; validation writes remain separately gated.
|
||||
public bool EnableValidationOperations { get; init; }
|
||||
public bool EnableListenerEvents { get; init; }
|
||||
public bool PreventAutoLock { get; init; }
|
||||
// Kept as a compatibility switch for older service.json files; a configured Agent connection authorizes sync.
|
||||
public bool EnableDataSync { get; init; } = true;
|
||||
public int DataSyncIntervalSeconds { get; init; } = 5;
|
||||
public int DataSyncBatchLimit { get; init; } = 100;
|
||||
public int DataSyncOverlapRows { get; init; } = 1;
|
||||
public int DataSyncQueueMaxItems { get; init; } = 1000;
|
||||
public long DataSyncQueueMaxBytes { get; init; } = 16 * 1024 * 1024;
|
||||
|
||||
// Kept only so older service.json files can be loaded and rewritten by the tray.
|
||||
[JsonIgnore]
|
||||
[Obsolete("Internal compatibility field; the value is ignored.")]
|
||||
public int QueueCapacity { get; init; } = 100;
|
||||
[JsonIgnore]
|
||||
[Obsolete("Internal compatibility field; the value is ignored.")]
|
||||
public string ListenerSession { get; init; } = "文件传输助手";
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (!Uri.TryCreate(ListenUrl, UriKind.Absolute, out var uri) || uri.Scheme != "http" ||
|
||||
|
||||
@@ -172,11 +172,9 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
DataDirectory = dataDirectory,
|
||||
Remote = source.Remote,
|
||||
Reporting = source.Reporting,
|
||||
RemoteConfigurationFile = source.RemoteConfigurationFile,
|
||||
EnableValidationOperations = source.EnableValidationOperations,
|
||||
EnableListenerEvents = source.EnableListenerEvents,
|
||||
PreventAutoLock = source.PreventAutoLock,
|
||||
EnableDataSync = source.EnableDataSync,
|
||||
DataSyncIntervalSeconds = source.DataSyncIntervalSeconds,
|
||||
DataSyncBatchLimit = source.DataSyncBatchLimit,
|
||||
DataSyncOverlapRows = source.DataSyncOverlapRows,
|
||||
@@ -288,12 +286,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
EnsureConfiguration();
|
||||
var settings = options!;
|
||||
if (!string.IsNullOrWhiteSpace(settings.RemoteConfigurationFile))
|
||||
{
|
||||
var remotePath = ResolvePath(settings.RemoteConfigurationFile, Path.GetDirectoryName(configPath)!, "remote.json");
|
||||
var remoteConfiguration = RemoteNodeConfigurationStore.LoadAsync(remotePath).GetAwaiter().GetResult();
|
||||
settings = WithRemoteForSettings(settings, remoteConfiguration.Remote, remoteConfiguration.Reporting);
|
||||
}
|
||||
if (ServiceSettingsEditor.Show(settings) is not { } edited) return;
|
||||
WriteCredentials(edited.CredentialFile, edited.AccessToken!, edited.EnableValidationOperations);
|
||||
WriteJson(configPath, edited);
|
||||
@@ -306,29 +298,6 @@ internal sealed class TrayApplicationContext : ApplicationContext
|
||||
}
|
||||
}
|
||||
|
||||
private static ServiceOptions WithRemoteForSettings(ServiceOptions source, RemoteAgentOptions? remote, ReportingConfig reporting) => new()
|
||||
{
|
||||
ListenUrl = source.ListenUrl,
|
||||
AllowExternal = source.AllowExternal,
|
||||
AllowedHosts = source.AllowedHosts ?? [],
|
||||
AllowedOrigins = source.AllowedOrigins ?? [],
|
||||
AccessToken = source.AccessToken,
|
||||
CredentialFile = source.CredentialFile,
|
||||
DataDirectory = source.DataDirectory,
|
||||
Remote = remote,
|
||||
Reporting = reporting,
|
||||
RemoteConfigurationFile = null,
|
||||
EnableValidationOperations = source.EnableValidationOperations,
|
||||
EnableListenerEvents = source.EnableListenerEvents,
|
||||
PreventAutoLock = source.PreventAutoLock,
|
||||
EnableDataSync = source.EnableDataSync,
|
||||
DataSyncIntervalSeconds = source.DataSyncIntervalSeconds,
|
||||
DataSyncBatchLimit = source.DataSyncBatchLimit,
|
||||
DataSyncOverlapRows = source.DataSyncOverlapRows,
|
||||
DataSyncQueueMaxItems = source.DataSyncQueueMaxItems,
|
||||
DataSyncQueueMaxBytes = source.DataSyncQueueMaxBytes
|
||||
};
|
||||
|
||||
private void SetStatus(string message)
|
||||
{
|
||||
StatusItem.Text = message.Length > 60 ? message[..60] : message;
|
||||
@@ -618,7 +587,6 @@ internal static class ServiceSettingsEditor
|
||||
DataDirectory = current.DataDirectory,
|
||||
Remote = remoteOptions,
|
||||
Reporting = reportingOptions,
|
||||
RemoteConfigurationFile = null,
|
||||
EnableValidationOperations = validation.Checked,
|
||||
EnableListenerEvents = listenerEvents.Checked,
|
||||
PreventAutoLock = preventAutoLock.Checked
|
||||
|
||||
@@ -79,15 +79,6 @@ public static partial class WechatChatClient
|
||||
item.DoubleClick();
|
||||
}
|
||||
|
||||
/// <summary>Compatibility name: returns database-known groups, not a recency-ordered session list.</summary>
|
||||
public static async Task<IReadOnlyList<string>> GetRecentGroupsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = await GetContactsPageAsync(limit: 10000, groupsOnly: true, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (page.HasMore)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Group list exceeds the compatibility API limit; use GetContactsPageAsync to paginate.");
|
||||
return page.Contacts.Select(contact => contact.DisplayName).ToArray();
|
||||
}
|
||||
|
||||
public static async Task<WechatContactPage> GetContactsPageAsync(int limit = 200, int offset = 0,
|
||||
string? contains = null, bool? groupsOnly = null, string? accountId = null, string? keyFile = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
@@ -265,14 +256,6 @@ public static partial class WechatChatClient
|
||||
public static Task<WechatOperationResult> SetMyNicknameInGroupAsync(string group, string value, string confirmation, CancellationToken cancellationToken = default) =>
|
||||
SetGroupTextAsync(group, new[] { "我在本群的昵称", "群昵称" }, value, "set group nickname", confirmation, cancellationToken);
|
||||
|
||||
public static async Task<IReadOnlyList<WechatGroupMemberSnapshot>> GetGroupMembersAsync(string group, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var page = await GetGroupMembersPageAsync(group, 10000, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (page.HasMore)
|
||||
throw new WxAgentException(WxAgentErrorCode.InvalidOperationState, "Group member list exceeds the compatibility API limit; use GetGroupMembersPageAsync.");
|
||||
return page.Members.Select(member => new WechatGroupMemberSnapshot(member.DisplayName, IsOwner: member.IsOwner)).ToArray();
|
||||
}
|
||||
|
||||
public static async Task<WechatGroupMemberPage> GetGroupMembersPageAsync(string group, int limit = 500, int offset = 0,
|
||||
string? accountId = null, string? keyFile = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
@@ -98,6 +98,7 @@ public sealed class RemoteReportingTests
|
||||
public async Task ConfigStoreLoadsInvalidConfigAsDenyAndUpdatesOneGlobalVersion()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "wxagent-reporting-" + Guid.NewGuid().ToString("N") + ".json");
|
||||
var nodePath = path + ".node.json";
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(path, "{\"enabled\":true,\"configVersion\":-1}");
|
||||
@@ -112,15 +113,15 @@ public sealed class RemoteReportingTests
|
||||
Assert.True(loaded.Enabled);
|
||||
Assert.Equal(4, loaded.ConfigVersion);
|
||||
|
||||
var nodePath = Path.Combine(Path.GetDirectoryName(path)!, "remote-node.json");
|
||||
var nodeConfig = (new RemoteNodeConfiguration { Reporting = loaded }).WithAudit("reporting.enable");
|
||||
await RemoteNodeConfigurationStore.SaveAsync(nodePath, nodeConfig);
|
||||
await File.WriteAllTextAsync(nodePath, "{\"reporting\":{\"enabled\":true,\"configVersion\":4}}");
|
||||
var nodeLoaded = await RemoteNodeConfigurationStore.LoadAsync(nodePath);
|
||||
Assert.Equal("reporting.enable", Assert.Single(nodeLoaded.Audit).Action);
|
||||
Assert.True(nodeLoaded.Reporting.Enabled);
|
||||
Assert.Equal(4, nodeLoaded.Reporting.ConfigVersion);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
if (File.Exists(nodePath)) File.Delete(nodePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,17 +28,6 @@ public sealed class ServiceBoundaryTests
|
||||
public Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken ct) => Task.FromResult<IReadOnlyList<AccountInfo>>([new("account-1", null, null, null, "fingerprint", false)]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DatabaseSyncIsAuthorizedByAgentConnectionByDefault()
|
||||
{
|
||||
var options = new ServiceOptions
|
||||
{
|
||||
CredentialFile = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")),
|
||||
DataDirectory = Path.GetTempPath()
|
||||
};
|
||||
Assert.True(options.EnableDataSync);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StaticUiAndBoundedReadOnlyPagesAreRealProductionRoutes()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user