This commit is contained in:
@@ -370,9 +370,10 @@ public sealed class WindowsAgentBackend(AccountBindingStore bindings, ServiceOpt
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(session))
|
||||
{
|
||||
var matches = await WechatChatClient.SearchSessionsAsync(session, exactOnly: true, cancellationToken: cancellationToken);
|
||||
if (matches.Count != 1) throw new ServiceException(matches.Count == 0 ? "NotFound" : "AmbiguousTarget", 409, "Session name is not unique or not found.");
|
||||
await WechatChatClient.OpenSessionAsync(matches[0].Name, cancellationToken, session);
|
||||
var requestedName = session.StartsWith("session_item_", StringComparison.Ordinal)
|
||||
? session["session_item_".Length..]
|
||||
: session;
|
||||
await WechatChatClient.OpenSessionAsync(requestedName, cancellationToken);
|
||||
}
|
||||
var messages = await WechatChatClient.ReadVisibleAsync(cancellationToken);
|
||||
return messages.Select(m => new MessageInfo(m.Fingerprint, m.Type.ToString(), m.Quote?.Sender,
|
||||
|
||||
@@ -64,9 +64,20 @@ public sealed class RemoteAgentHostedService(
|
||||
await client.HeartbeatAsync(CreateHeartbeat(remote, reporting, snapshot, null), stoppingToken);
|
||||
await ReplayUnreportedResultsAsync(client, ledger, reporting, stoppingToken);
|
||||
await client.FlushEventsAsync(eventQueue, reporting, stoppingToken);
|
||||
foreach (var account in reporting.Accounts.Where(account => account.Enabled))
|
||||
var pollAccountIds = reporting.Accounts
|
||||
.Where(account => account.Enabled)
|
||||
.Select(account => account.AccountId)
|
||||
.ToList();
|
||||
if (snapshot.ActiveAccountId is { Length: > 0 } activeAccountId &&
|
||||
!pollAccountIds.Contains(activeAccountId, StringComparer.Ordinal))
|
||||
{
|
||||
var tasks = await client.PollTasksAsync(account.AccountId, stoppingToken, waitSeconds: 5);
|
||||
// Poll the verified active account even before a Reporting whitelist is configured.
|
||||
// The task then returns an explicit authorization result instead of staying Pending.
|
||||
pollAccountIds.Add(activeAccountId);
|
||||
}
|
||||
foreach (var accountId in pollAccountIds)
|
||||
{
|
||||
var tasks = await client.PollTasksAsync(accountId, stoppingToken, waitSeconds: 5);
|
||||
foreach (var task in tasks)
|
||||
{
|
||||
await ProcessTaskAsync(client, remote, reporting, ledger, task, stoppingToken);
|
||||
@@ -254,11 +265,13 @@ public sealed class RemoteAgentHostedService(
|
||||
var payload = ReadPayload<ReadSessionsPayload>(task.Payload);
|
||||
ValidatePage(payload.Limit, payload.Offset);
|
||||
var scopes = AuthorizedChatScopes(reporting, task.AccountId);
|
||||
RequireScopes(scopes);
|
||||
var sessions = (await backend.SessionsAsync(task.AccountId, cancellationToken))
|
||||
.Where(session => scopes.Any(scope => scope.ChatId == session.AutomationId))
|
||||
RequireScopes(reporting, task.AccountId, scopes);
|
||||
var sessions = await backend.SessionsAsync(task.AccountId, cancellationToken);
|
||||
var contacts = await ReadContactsForScopesAsync(task.AccountId, scopes, cancellationToken);
|
||||
var visibleSessions = sessions
|
||||
.Where(session => scopes.Any(scope => SessionMatchesScope(session, scope, contacts)))
|
||||
.ToArray();
|
||||
var page = sessions.ToPage(payload.Limit, payload.Offset);
|
||||
var page = visibleSessions.ToPage(payload.Limit, payload.Offset);
|
||||
return new RemoteReadExecution(JsonSerializer.SerializeToElement(page, RemoteJson.Options), scopes);
|
||||
}
|
||||
case "read-contacts":
|
||||
@@ -267,7 +280,7 @@ public sealed class RemoteAgentHostedService(
|
||||
ValidatePage(payload.Limit, payload.Offset);
|
||||
var chatType = payload.GroupsOnly ? ReportingChatType.Group : ReportingChatType.Private;
|
||||
var scopes = AuthorizedChatScopes(reporting, task.AccountId, chatType);
|
||||
RequireScopes(scopes);
|
||||
RequireScopes(reporting, task.AccountId, scopes);
|
||||
var all = new List<ContactInfo>();
|
||||
for (var offset = 0; ;)
|
||||
{
|
||||
@@ -288,17 +301,31 @@ public sealed class RemoteAgentHostedService(
|
||||
{
|
||||
var payload = ReadPayload<ReadMessagesPayload>(task.Payload);
|
||||
ValidatePage(payload.Limit, payload.Offset);
|
||||
var scopes = AuthorizedChatScopes(reporting, task.AccountId)
|
||||
.Where(scope => string.Equals(scope.ChatId, payload.ChatId, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
if (scopes.Length != 1)
|
||||
throw new ServiceException("ChatNotAuthorized", 403, "The requested chat is not enabled in the local whitelist.");
|
||||
var sessions = (await backend.SessionsAsync(task.AccountId, cancellationToken))
|
||||
var scopes = AuthorizedChatScopes(reporting, task.AccountId);
|
||||
RequireScopes(reporting, task.AccountId, scopes);
|
||||
var sessions = await backend.SessionsAsync(task.AccountId, cancellationToken);
|
||||
var contacts = await ReadContactsForScopesAsync(task.AccountId, scopes, cancellationToken);
|
||||
var candidateSessions = sessions
|
||||
.Where(session => string.Equals(session.AutomationId, payload.ChatId, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
if (sessions.Length != 1)
|
||||
var scope = scopes.FirstOrDefault(allowed => string.Equals(allowed.ChatId, payload.ChatId, StringComparison.Ordinal));
|
||||
if (scope is null)
|
||||
{
|
||||
var mapped = sessions
|
||||
.Where(session => scopes.Any(allowed => SessionMatchesScope(session, allowed, contacts)))
|
||||
.Where(session => string.Equals(session.AutomationId, payload.ChatId, StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
if (mapped.Length == 1)
|
||||
{
|
||||
candidateSessions = mapped;
|
||||
scope = scopes.First(allowed => SessionMatchesScope(mapped[0], allowed, contacts));
|
||||
}
|
||||
}
|
||||
if (scope is null)
|
||||
throw new ServiceException("ChatNotAuthorized", 403, "The requested chat is not enabled in the local whitelist.");
|
||||
if (candidateSessions.Length != 1)
|
||||
throw new ServiceException("ChatIdentityUnconfirmed", 409, "The requested chat identity is not uniquely visible.");
|
||||
var messages = await backend.MessagesAsync(task.AccountId, sessions[0].Name, payload.IncludeContent, cancellationToken);
|
||||
var messages = await backend.MessagesAsync(task.AccountId, candidateSessions[0].AutomationId, payload.IncludeContent, cancellationToken);
|
||||
var page = messages.ToPage(payload.Limit, payload.Offset);
|
||||
return new RemoteReadExecution(JsonSerializer.SerializeToElement(page, RemoteJson.Options), scopes);
|
||||
}
|
||||
@@ -307,6 +334,44 @@ public sealed class RemoteAgentHostedService(
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<ContactInfo>> ReadContactsForScopesAsync(
|
||||
string accountId, IReadOnlyList<RemoteReportingScope> scopes, CancellationToken cancellationToken)
|
||||
{
|
||||
var contacts = new Dictionary<string, ContactInfo>(StringComparer.Ordinal);
|
||||
foreach (var scope in scopes)
|
||||
{
|
||||
for (var offset = 0; ;)
|
||||
{
|
||||
var page = await backend.ContactsAsync(
|
||||
accountId,
|
||||
contains: scope.ChatId,
|
||||
groupsOnly: scope.ChatType == ReportingChatType.Group,
|
||||
200,
|
||||
offset,
|
||||
cancellationToken);
|
||||
foreach (var contact in page.Items.Where(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal)))
|
||||
contacts[contact.Id] = contact;
|
||||
if (!page.HasMore) break;
|
||||
var next = page.NextOffset ?? offset + page.Items.Count;
|
||||
if (next <= offset)
|
||||
throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page.");
|
||||
offset = next;
|
||||
}
|
||||
}
|
||||
return contacts.Values.ToArray();
|
||||
}
|
||||
|
||||
private static bool SessionMatchesScope(SessionInfo session, RemoteReportingScope scope, IReadOnlyList<ContactInfo> contacts)
|
||||
{
|
||||
if (string.Equals(scope.ChatId, session.AutomationId, StringComparison.Ordinal) ||
|
||||
string.Equals(scope.ChatId, session.Name, StringComparison.Ordinal))
|
||||
return true;
|
||||
var matches = contacts.Where(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal)).ToArray();
|
||||
return matches.Length == 1 && matches[0] is { } contact &&
|
||||
(string.Equals(contact.DisplayName, session.Name, StringComparison.Ordinal) ||
|
||||
string.Equals(contact.Remark, session.Name, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<RemoteReportingScope> AuthorizedChatScopes(
|
||||
ReportingConfig reporting, string accountId, ReportingChatType? chatType = null) =>
|
||||
(reporting.FindAccount(accountId)?.AllowedChats ?? [])
|
||||
@@ -316,8 +381,13 @@ public sealed class RemoteAgentHostedService(
|
||||
.Distinct()
|
||||
.ToArray();
|
||||
|
||||
private static void RequireScopes(IReadOnlyList<RemoteReportingScope> scopes)
|
||||
private static void RequireScopes(ReportingConfig reporting, string accountId, IReadOnlyList<RemoteReportingScope> scopes)
|
||||
{
|
||||
if (!reporting.Enabled)
|
||||
throw new ServiceException("ReportingDisabled", 403, "Reporting is disabled on the node.");
|
||||
var account = reporting.FindAccount(accountId);
|
||||
if (account is null || !account.Enabled)
|
||||
throw new ServiceException("AccountNotAuthorized", 403, "The target account is not enabled in the local Reporting configuration.");
|
||||
if (scopes.Count == 0)
|
||||
throw new ServiceException("ChatNotAuthorized", 403, "The requested data scope is not enabled in the local whitelist.");
|
||||
}
|
||||
|
||||
@@ -379,23 +379,33 @@ internal static class ServiceSettingsEditor
|
||||
throw new InvalidDataException("ListenUrl is invalid.");
|
||||
|
||||
static string? Optional(TextBox box) => string.IsNullOrWhiteSpace(box.Text) ? null : box.Text.Trim();
|
||||
static string[] Lines(TextBox box) => box.Lines
|
||||
.Select(line => line.Trim())
|
||||
.Where(line => line.Length > 0)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
var host = uri.Host.Trim('[', ']');
|
||||
var token = current.AccessToken ?? ServiceOptions.GenerateToken();
|
||||
var remote = current.Remote;
|
||||
var reporting = (current.Reporting ?? new ReportingConfig()).NormalizeAndValidate();
|
||||
var reportingAccount = reporting.Accounts.FirstOrDefault();
|
||||
var boundAccounts = new AccountBindingStore(current).ReadAll();
|
||||
var reportingAccountId = reportingAccount?.AccountId
|
||||
?? (boundAccounts.Count == 1 ? boundAccounts[0].AccountId : "");
|
||||
ServiceOptions? result = null;
|
||||
using var form = new Form
|
||||
{
|
||||
Text = "WxAgent 服务设置",
|
||||
Width = 720,
|
||||
Height = 535,
|
||||
Height = 805,
|
||||
StartPosition = FormStartPosition.CenterScreen,
|
||||
MinimizeBox = false,
|
||||
MaximizeBox = false,
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog
|
||||
};
|
||||
|
||||
var tabs = new TabControl { Left = 12, Top = 12, Width = 680, Height = 450 };
|
||||
var tabs = new TabControl { Left = 12, Top = 12, Width = 680, Height = 720 };
|
||||
var localTab = new TabPage("本地服务");
|
||||
var remoteTab = new TabPage("远程连接");
|
||||
tabs.TabPages.Add(localTab);
|
||||
@@ -477,24 +487,84 @@ internal static class ServiceSettingsEditor
|
||||
Left = 18, Top = 372, Width = 620, Height = 40,
|
||||
Text = "控制面令牌与控制面 WXAGENT_NODE_TOKEN 一致;HTTP 仅允许私有 IP,公网或域名请使用 HTTPS。"
|
||||
};
|
||||
var reportingTitle = new Label
|
||||
{
|
||||
Left = 18, Top = 416, Width = 620, Height = 24,
|
||||
Text = "远程读取白名单(不填写不会默认放行)"
|
||||
};
|
||||
var reportingEnabled = new CheckBox
|
||||
{
|
||||
Left = 18, Top = 442, Width = 620,
|
||||
Text = "启用 Reporting 数据读取",
|
||||
Checked = reporting.Enabled
|
||||
};
|
||||
var reportingAccountEnabled = new CheckBox
|
||||
{
|
||||
Left = 150, Top = 470, Width = 485,
|
||||
Text = "启用当前账号",
|
||||
Checked = reportingAccount?.Enabled ?? true
|
||||
};
|
||||
var reportingAccountLabel = new Label { Left = 18, Top = 505, Width = 125, Text = "账号 ID" };
|
||||
var reportingAccountBox = new TextBox { Left = 150, Top = 501, Width = 485, Text = reportingAccountId };
|
||||
var groupChatsLabel = new Label { Left = 18, Top = 541, Width = 125, Text = "群聊白名单" };
|
||||
var groupChatsBox = new TextBox
|
||||
{
|
||||
Left = 150, Top = 537, Width = 485, Height = 48,
|
||||
Multiline = true, AcceptsReturn = true, ScrollBars = ScrollBars.Vertical,
|
||||
Text = string.Join(Environment.NewLine, reportingAccount?.AllowedChats
|
||||
.Where(chat => chat.Type == ReportingChatType.Group).Select(chat => chat.ChatId) ?? [])
|
||||
};
|
||||
var privateChatsLabel = new Label { Left = 18, Top = 593, Width = 125, Text = "私聊白名单" };
|
||||
var privateChatsBox = new TextBox
|
||||
{
|
||||
Left = 150, Top = 589, Width = 485, Height = 48,
|
||||
Multiline = true, AcceptsReturn = true, ScrollBars = ScrollBars.Vertical,
|
||||
Text = string.Join(Environment.NewLine, reportingAccount?.AllowedChats
|
||||
.Where(chat => chat.Type == ReportingChatType.Private).Select(chat => chat.ChatId) ?? [])
|
||||
};
|
||||
var reportingIdentityConfirmed = new CheckBox
|
||||
{
|
||||
Left = 150, Top = 645, Width = 485,
|
||||
Text = "确认上述 chatId 已与微信身份核对",
|
||||
Checked = reportingAccount is not null && reportingAccount.AllowedChats.Count > 0 && reportingAccount.AllowedChats.All(chat => chat.IdentityVerified)
|
||||
};
|
||||
var reportingNote = new Label
|
||||
{
|
||||
Left = 18, Top = 674, Width = 620, Height = 36,
|
||||
Text = "每行一个 chatId;通讯录/会话读取只返回白名单范围。"
|
||||
};
|
||||
remoteTab.Controls.AddRange([
|
||||
remoteEnabled, remoteAddressLabel, remoteAddressBox, remoteNodeLabel, remoteNodeBox,
|
||||
remoteAccountLabel, remoteAccountBox, remoteTokenLabel, remoteTokenBox,
|
||||
remoteTokenFileLabel, remoteTokenFileBox, allowInsecureHttp, remoteServerCaLabel,
|
||||
remoteServerCaBox, remoteClientCertLabel, remoteClientCertBox, remoteClientKeyLabel,
|
||||
remoteClientKeyBox, remoteNote
|
||||
remoteClientKeyBox, remoteNote, reportingTitle, reportingEnabled, reportingAccountEnabled,
|
||||
reportingAccountLabel, reportingAccountBox, groupChatsLabel, groupChatsBox,
|
||||
privateChatsLabel, privateChatsBox, reportingIdentityConfirmed, reportingNote
|
||||
]);
|
||||
var remoteInputs = new Control[]
|
||||
{
|
||||
remoteAddressBox, remoteNodeBox, remoteAccountBox, remoteTokenBox, remoteTokenFileBox,
|
||||
allowInsecureHttp, remoteServerCaBox, remoteClientCertBox, remoteClientKeyBox
|
||||
};
|
||||
void SetRemoteEnabled() { foreach (var control in remoteInputs) control.Enabled = remoteEnabled.Checked; }
|
||||
var reportingInputs = new Control[]
|
||||
{
|
||||
reportingAccountEnabled, reportingAccountBox, groupChatsBox, privateChatsBox,
|
||||
reportingIdentityConfirmed
|
||||
};
|
||||
void SetRemoteEnabled()
|
||||
{
|
||||
foreach (var control in remoteInputs) control.Enabled = remoteEnabled.Checked;
|
||||
reportingEnabled.Enabled = remoteEnabled.Checked;
|
||||
var reportingInputsEnabled = remoteEnabled.Checked && reportingEnabled.Checked;
|
||||
foreach (var control in reportingInputs) control.Enabled = reportingInputsEnabled;
|
||||
}
|
||||
remoteEnabled.CheckedChanged += (_, _) => SetRemoteEnabled();
|
||||
reportingEnabled.CheckedChanged += (_, _) => SetRemoteEnabled();
|
||||
SetRemoteEnabled();
|
||||
|
||||
var save = new Button { Left = 470, Top = 470, Width = 105, Text = "保存" };
|
||||
var cancel = new Button { Left = 585, Top = 470, Width = 105, Text = "取消" };
|
||||
var save = new Button { Left = 470, Top = 740, Width = 105, Text = "保存" };
|
||||
var cancel = new Button { Left = 585, Top = 740, Width = 105, Text = "取消" };
|
||||
copy.Click += (_, _) => { Clipboard.SetText(tokenBox.Text); copy.Text = "已复制"; };
|
||||
regenerate.Click += (_, _) =>
|
||||
{
|
||||
@@ -525,6 +595,49 @@ internal static class ServiceSettingsEditor
|
||||
AllowInsecureHttp = allowInsecureHttp.Checked
|
||||
}
|
||||
: null;
|
||||
var reportingOptions = reporting;
|
||||
if (remoteEnabled.Checked && reportingEnabled.Checked)
|
||||
{
|
||||
var accountId = Optional(reportingAccountBox);
|
||||
if (accountId is null)
|
||||
{
|
||||
MessageBox.Show("启用 Reporting 时必须填写账号 ID。", "WxAgent 设置无效", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
return;
|
||||
}
|
||||
var allowedChats = Lines(groupChatsBox).Select(chatId => new AllowedChat
|
||||
{
|
||||
Type = ReportingChatType.Group,
|
||||
ChatId = chatId,
|
||||
Enabled = true,
|
||||
IdentityVerified = reportingIdentityConfirmed.Checked
|
||||
}).Concat(Lines(privateChatsBox).Select(chatId => new AllowedChat
|
||||
{
|
||||
Type = ReportingChatType.Private,
|
||||
ChatId = chatId,
|
||||
Enabled = true,
|
||||
IdentityVerified = reportingIdentityConfirmed.Checked
|
||||
})).ToArray();
|
||||
var account = new AccountReportingConfig
|
||||
{
|
||||
AccountId = accountId,
|
||||
Enabled = reportingAccountEnabled.Checked,
|
||||
AllowedChats = allowedChats
|
||||
};
|
||||
var accounts = reporting.Accounts
|
||||
.Where(existing => !string.Equals(existing.AccountId, accountId, StringComparison.Ordinal))
|
||||
.Append(account)
|
||||
.ToArray();
|
||||
reportingOptions = reporting with
|
||||
{
|
||||
Enabled = true,
|
||||
ConfigVersion = checked(reporting.ConfigVersion + 1),
|
||||
Accounts = accounts
|
||||
};
|
||||
}
|
||||
else if (remoteEnabled.Checked && !reportingEnabled.Checked)
|
||||
{
|
||||
reportingOptions = reporting with { Enabled = false };
|
||||
}
|
||||
var edited = new ServiceOptions
|
||||
{
|
||||
ListenUrl = $"http://{formattedHost}:{portBox.Value}",
|
||||
@@ -535,7 +648,7 @@ internal static class ServiceSettingsEditor
|
||||
CredentialFile = current.CredentialFile,
|
||||
DataDirectory = current.DataDirectory,
|
||||
Remote = remoteOptions,
|
||||
Reporting = current.Reporting,
|
||||
Reporting = reportingOptions,
|
||||
RemoteConfigurationFile = null,
|
||||
EnableValidationOperations = validation.Checked,
|
||||
EnableListenerEvents = listenerEvents.Checked,
|
||||
|
||||
@@ -98,25 +98,10 @@ public static partial class WechatChatClient
|
||||
var selected = await OpenLocalSearchSessionAsync(window, name, cancellationToken, query).ConfigureAwait(false);
|
||||
return new WechatSessionSnapshot(name, selected.AutomationId, true);
|
||||
}
|
||||
if (SafeName(FindByAutomationId(window, WechatLocators.CurrentChatName)) == name)
|
||||
{
|
||||
return new WechatSessionSnapshot(name, SafeAutomationId(FindByAutomationId(window, WechatLocators.CurrentChatName)!), true);
|
||||
}
|
||||
|
||||
var target = FindByAutomationId(window, "session_item_" + name);
|
||||
if (target is null)
|
||||
{
|
||||
var selected = await OpenLocalSearchSessionAsync(window, name, cancellationToken).ConfigureAwait(false);
|
||||
return new WechatSessionSnapshot(name, selected.AutomationId, true);
|
||||
}
|
||||
|
||||
ClickCenter(target);
|
||||
if (!await WaitForSessionAsync(window, name, TimeSpan.FromSeconds(10), cancellationToken))
|
||||
{
|
||||
throw new WxAgentException(WxAgentErrorCode.ResultUnconfirmed, "The requested session did not become active.");
|
||||
}
|
||||
|
||||
return new WechatSessionSnapshot(name, SafeAutomationId(target), true);
|
||||
await OpenNamedSessionAsync(window, name, cancellationToken).ConfigureAwait(false);
|
||||
var current = FindByAutomationId(window, WechatLocators.CurrentChatName);
|
||||
var currentAutomationId = current is null ? null : SafeAutomationId(current);
|
||||
return new WechatSessionSnapshot(name, currentAutomationId ?? "session_item_" + name, true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user