Files
wx-win-agent/node-agent/WxAgent.Service/AgentService.cs
T

332 lines
21 KiB
C#

using Microsoft.AspNetCore.Http;
using WxAgent.Core;
namespace WxAgent.Service;
public sealed record AgentCapability(string Operation, bool Implemented, bool Validated, bool Enabled,
bool RequiresUi, bool HasSideEffects, string Permission, bool RequiresConfirmation, int TimeoutSeconds,
string? DisabledReason, string[] Evidence, string? WechatVersion = null, int ContractVersion = 1);
// The only platform seam: Windows is composed by Host; route tests load this assembly unchanged.
public interface IAgentBackend
{
IReadOnlyList<AgentCapability> Capabilities { get; }
Task<object> StatusAsync(CancellationToken cancellationToken);
Task<object> DiagnoseAsync(CancellationToken cancellationToken) => StatusAsync(cancellationToken);
Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<AccountInfo>>([]);
Task<IReadOnlyList<AccountInfo>> AccountsAsync(CancellationToken cancellationToken, bool refreshUiIdentity) => AccountsAsync(cancellationToken);
Task<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<UiTargetInfo>>([]);
Task<AccountBinding> BindAccountAsync(string accountId, string targetId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available.");
Task UnbindAccountAsync(string accountId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Account binding is not available.");
Task<IReadOnlyList<SessionInfo>> SessionsAsync(CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<SessionInfo>>([]);
Task<IReadOnlyList<SessionInfo>> SessionsAsync(string? accountId, CancellationToken cancellationToken) => SessionsAsync(cancellationToken);
Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<SessionSearchInfo>>([]);
Task<IReadOnlyList<SessionSearchInfo>> SearchSessionsAsync(string? accountId, string query, bool exactOnly, CancellationToken cancellationToken) => SearchSessionsAsync(query, exactOnly, cancellationToken);
Task<SessionInfo?> CurrentSessionAsync(CancellationToken cancellationToken) => Task.FromResult<SessionInfo?>(null);
Task<SessionInfo?> CurrentSessionAsync(string? accountId, CancellationToken cancellationToken) => CurrentSessionAsync(cancellationToken);
Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Session opening is not available.");
Task<SessionInfo> OpenSessionAsync(string? accountId, string automationId, CancellationToken cancellationToken) => OpenSessionAsync(automationId, cancellationToken);
Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken cancellationToken) => Task.FromResult(new SessionViewportInfo(0, false, []));
Task<SessionViewportInfo> ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken cancellationToken) => ScrollSessionsAsync(direction, pages, cancellationToken);
Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? session, bool includeContent, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<MessageInfo>>([]);
Task<IReadOnlyList<MessageInfo>> MessagesAsync(string? accountId, string? session, bool includeContent, CancellationToken cancellationToken) => MessagesAsync(session, includeContent, cancellationToken);
Task SendTextAsync(string accountId, string targetId, string text, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Text sending is not available.");
Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page<ContactInfo>([], limit, offset, false, null));
Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken cancellationToken) => Task.FromResult(new Page<GroupMemberInfo>([], limit, offset, false, null));
Task<IReadOnlyList<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, long? localId, CancellationToken cancellationToken) => Task.FromResult<IReadOnlyList<DatabaseMessageInfo>>([]);
Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken cancellationToken) => throw new ServiceException("Unsupported", 501, "Merged database messages are not available.");
}
public sealed class AgentService(IAgentBackend backend, ServiceSecurity security, IHttpContextAccessor contexts, OperationQueue operations, ArtifactStore artifacts, AccountBindingStore bindings)
{
public ServiceIdentity Identity => contexts.HttpContext?.Items[typeof(ServiceIdentity)] as ServiceIdentity
?? throw new ServiceException("Unauthorized", 401, "Authentication required.");
internal void RequireEvents() => RequireCapability("listener-events");
private AgentCapability RequireCapability(string operation)
{
var capability = backend.Capabilities.SingleOrDefault(c => c.Operation == operation)
?? throw new ServiceException("Unsupported", 501, "Capability is not implemented.");
security.RequireCurrent(Identity, capability.Permission);
if (!capability.Enabled) throw new ServiceException("CapabilityDisabled", 409, capability.DisabledReason ?? "Capability is disabled.");
return capability;
}
public async Task<object> DiagnoseAsync(CancellationToken cancellationToken)
{ RequireCapability("agent-diagnose"); return await backend.DiagnoseAsync(cancellationToken); }
public async Task<object> StatusAsync(CancellationToken cancellationToken)
{
RequireCapability("agent-status");
var result = await backend.StatusAsync(cancellationToken);
security.RequireCurrent(Identity, "read");
return result;
}
public async Task<Page<AccountInfo>> AccountsAsync(int limit, int offset, CancellationToken ct)
{
RequireCapability("accounts-list"); ReadOnlyRequest.Page(limit, offset);
var accounts = (await backend.AccountsAsync(ct)).Select(account =>
{
var binding = bindings.Get(account.AccountId);
return account with { Binding = binding, IsUiBindingKnown = account.BindingStatus == "Bound" };
}).ToArray();
return accounts.ToPage(limit, offset);
}
public IReadOnlyList<AccountBinding> Bindings()
{ security.RequireCurrent(Identity, "read"); return bindings.ReadAll(); }
public async Task<IReadOnlyList<UiTargetInfo>> UiTargetsAsync(CancellationToken ct)
{ security.RequireCurrent(Identity, "read"); return await backend.UiTargetsAsync(ct); }
public async Task<AccountBinding> BindAccountAsync(string accountId, string targetId, CancellationToken ct)
{
RequireCapability("account-binding");
if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200 || string.IsNullOrWhiteSpace(targetId) || targetId.Length > 200)
throw new ServiceException("InvalidRequest", 400, "accountId and targetId are required and bounded.");
return await backend.BindAccountAsync(accountId, targetId, ct);
}
public async Task UnbindAccountAsync(string accountId, CancellationToken ct)
{
RequireCapability("account-binding");
if (string.IsNullOrWhiteSpace(accountId) || accountId.Length > 200)
throw new ServiceException("InvalidRequest", 400, "accountId is required and bounded.");
await backend.UnbindAccountAsync(accountId, ct);
}
public async Task<Page<SessionInfo>> SessionsAsync(string? accountId, int limit, int offset, CancellationToken ct)
{ RequireCapability("sessions-list"); ReadOnlyRequest.Page(limit, offset); return (await backend.SessionsAsync(accountId, ct)).ToPage(limit, offset); }
public Task<Page<SessionInfo>> SessionsAsync(int limit, int offset, CancellationToken ct) => SessionsAsync(null, limit, offset, ct);
public async Task<Page<SessionSearchInfo>> SearchSessionsAsync(string? accountId, string query, bool exactOnly, int limit, int offset, CancellationToken ct)
{ RequireCapability("sessions-search"); ReadOnlyRequest.Page(limit, offset); if (string.IsNullOrWhiteSpace(query) || query.Length > 200) throw new ServiceException("InvalidRequest", 400, "query must be 1..200 characters."); return (await backend.SearchSessionsAsync(accountId, query, exactOnly, ct)).ToPage(limit, offset); }
public Task<Page<SessionSearchInfo>> SearchSessionsAsync(string query, bool exactOnly, int limit, int offset, CancellationToken ct) => SearchSessionsAsync(null, query, exactOnly, limit, offset, ct);
public async Task<SessionInfo> CurrentSessionAsync(string? accountId, CancellationToken ct)
{ RequireCapability("session-current"); return await backend.CurrentSessionAsync(accountId, ct) ?? throw new ServiceException("NotFound", 404, "No current session."); }
public Task<SessionInfo> CurrentSessionAsync(CancellationToken ct) => CurrentSessionAsync(null, ct);
public async Task<SessionInfo> OpenSessionAsync(string? accountId, string automationId, CancellationToken ct)
{ RequireCapability("session-open"); if (string.IsNullOrWhiteSpace(automationId) || automationId.Length > 512) throw new ServiceException("InvalidRequest", 400, "automationId is required and bounded."); return await backend.OpenSessionAsync(accountId, automationId, ct); }
public Task<SessionInfo> OpenSessionAsync(string automationId, CancellationToken ct) => OpenSessionAsync(null, automationId, ct);
public async Task<SessionViewportInfo> ScrollSessionsAsync(string? accountId, string direction, int pages, CancellationToken ct)
{ RequireCapability("sessions-scroll"); if (pages is < 1 or > 10 || direction is not ("up" or "down")) throw new ServiceException("InvalidRequest", 400, "direction must be up/down and pages must be 1..10."); return await backend.ScrollSessionsAsync(accountId, direction, pages, ct); }
public Task<SessionViewportInfo> ScrollSessionsAsync(string direction, int pages, CancellationToken ct) => ScrollSessionsAsync(null, direction, pages, ct);
public async Task<Page<MessageInfo>> MessagesAsync(string? accountId, string? session, int limit, int offset, bool includeContent, CancellationToken ct)
{
RequireCapability("messages-read");
var identity = security.RequireCurrent(Identity, includeContent ? "content" : "read");
ReadOnlyRequest.Page(limit, offset);
var values = await backend.MessagesAsync(accountId, session, includeContent && identity.Allows("content"), ct);
return values.ToPage(limit, offset);
}
public Task<Page<MessageInfo>> MessagesAsync(string? session, int limit, int offset, bool includeContent, CancellationToken ct) =>
MessagesAsync(null, session, limit, offset, includeContent, ct);
public async Task<OperationRecord> SubmitOperationAsync(OperationSubmitRequest request, CancellationToken ct)
{
var kind = RequireText(request.Kind, "kind", 80);
var accountId = RequireText(request.AccountId, "accountId", 200);
var text = PrepareText(request.Text);
var idempotencyKey = RequireText(request.IdempotencyKey, "idempotencyKey", 128);
if (!request.Confirmed) throw new ServiceException("ConfirmationRequired", 409, "Explicit send confirmation is required.");
if (string.Equals(kind, "send-text", StringComparison.Ordinal))
{
var targetId = RequireText(request.TargetId, "targetId", 512);
var capability = RequireCapability("send-text");
var canonical = System.Text.Json.JsonSerializer.Serialize(new { kind, accountId, targetId, text });
return operations.Submit(Identity, accountId, capability, idempotencyKey, canonical,
cancellation => backend.SendTextAsync(accountId, targetId, text, cancellation));
}
if (!string.Equals(kind, "broadcast-text", StringComparison.Ordinal))
throw new ServiceException("UnsupportedOperation", 400, "Only send-text and broadcast-text are available in this phase.");
if (!string.IsNullOrWhiteSpace(request.TargetId))
throw new ServiceException("InvalidRequest", 400, "broadcast-text uses the targets list, not targetId.");
var capabilityForBroadcast = RequireCapability("broadcast-text");
var targets = await FreezeBroadcastTargetsAsync(accountId, request.Targets, ct).ConfigureAwait(false);
var initialDetails = System.Text.Json.JsonSerializer.Serialize(new
{
targetIds = targets,
stopOnError = request.StopOnError
}, ServiceHost.Json);
var canonicalBroadcast = System.Text.Json.JsonSerializer.Serialize(new
{
kind,
accountId,
targetIds = targets,
text,
stopOnError = request.StopOnError
});
return operations.SubmitResult(Identity, accountId, capabilityForBroadcast, idempotencyKey, canonicalBroadcast,
initialDetails,
cancellation => ExecuteBroadcastAsync(accountId, targets, text, request.StopOnError, cancellation));
}
private async Task<IReadOnlyList<string>> FreezeBroadcastTargetsAsync(string accountId, IReadOnlyList<string>? requested, CancellationToken ct)
{
if (requested is null || requested.Count is < 1 or > 20)
throw new ServiceException("InvalidRequest", 400, "broadcast-text requires 1..20 targets.");
var targets = requested
.Select(target => RequireText(target, "target", 512))
.Distinct(StringComparer.Ordinal)
.ToArray();
if (targets.Length == 0) throw new ServiceException("InvalidRequest", 400, "broadcast-text requires at least one unique target.");
// Freeze the current UI target list before enqueueing. The account binding and identity are
// still revalidated by every side-effecting send, so this preflight cannot bypass account safety.
var visible = await backend.SessionsAsync(ct).ConfigureAwait(false);
var visibleIds = visible.Select(session => session.AutomationId).ToHashSet(StringComparer.Ordinal);
var missing = targets.Where(target => !visibleIds.Contains(target)).ToArray();
if (missing.Length > 0)
throw new ServiceException("TargetNotFound", 409, "Every broadcast target must be a visible, uniquely bound session.");
return targets;
}
private async Task<OperationExecutionResult> ExecuteBroadcastAsync(string accountId, IReadOnlyList<string> targets,
string text, bool stopOnError, CancellationToken ct)
{
var items = new List<BroadcastItemResult>(targets.Count);
var stopped = false;
string? stopReason = null;
string? errorCode = null;
var unconfirmed = false;
foreach (var target in targets)
{
try
{
await backend.SendTextAsync(accountId, target, text, ct).ConfigureAwait(false);
items.Add(new BroadcastItemResult(target, "Succeeded", null));
}
catch (OperationCanceledException)
{
items.Add(new BroadcastItemResult(target, "Unconfirmed", "Cancelled"));
stopped = true;
stopReason = "Cancelled";
errorCode = "Cancelled";
unconfirmed = true;
break;
}
catch (ServiceException exception)
{
var itemState = exception.Code == "ResultUnconfirmed" ? "Unconfirmed" : "Failed";
items.Add(new BroadcastItemResult(target, itemState, exception.Code));
errorCode ??= exception.Code == "ResultUnconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
if (itemState == "Unconfirmed" || stopOnError)
{
stopped = true;
stopReason = exception.Code;
unconfirmed = itemState == "Unconfirmed";
break;
}
}
catch (WxAgentException exception)
{
var code = exception.Code.ToString();
var itemState = exception.Code == WxAgentErrorCode.ResultUnconfirmed ? "Unconfirmed" : "Failed";
items.Add(new BroadcastItemResult(target, itemState, code));
errorCode ??= itemState == "Unconfirmed" ? "ResultUnconfirmed" : "BroadcastItemFailed";
if (itemState == "Unconfirmed" || stopOnError)
{
stopped = true;
stopReason = code;
unconfirmed = itemState == "Unconfirmed";
break;
}
}
catch (Exception)
{
items.Add(new BroadcastItemResult(target, "Unconfirmed", "ExecutionFailed"));
stopped = true;
stopReason = "ExecutionFailed";
errorCode = "ExecutionFailed";
unconfirmed = true;
break;
}
}
var details = System.Text.Json.JsonSerializer.Serialize(new BroadcastResult(targets, items, stopped, stopReason), ServiceHost.Json);
return new OperationExecutionResult(details, errorCode, unconfirmed);
}
private static string RequireText(string? value, string name, int maxLength)
{
if (string.IsNullOrWhiteSpace(value) || value.Length > maxLength)
throw new ServiceException("InvalidRequest", 400, $"{name} is required and bounded.");
return value;
}
private static string PrepareText(string? value)
{
try { return WechatTextInput.Prepare(value ?? string.Empty); }
catch (WxAgentException exception) { throw new ServiceException("InvalidRequest", 400, exception.Message); }
}
public async Task<Page<ContactInfo>> ContactsAsync(string? accountId, string? contains, bool? groupsOnly, int limit, int offset, CancellationToken ct)
{
RequireCapability("contacts-list");
var identity = security.RequireCurrent(Identity, "read");
ReadOnlyRequest.Page(limit, offset); if (!string.IsNullOrWhiteSpace(accountId)) security.RequireCurrent(identity, "read", accountId);
return await backend.ContactsAsync(accountId, contains, groupsOnly, limit, offset, ct);
}
public async Task<Page<GroupMemberInfo>> GroupMembersAsync(string accountId, string group, int limit, int offset, CancellationToken ct)
{
RequireCapability("group-members");
var identity = security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset);
return await backend.GroupMembersAsync(accountId, group, limit, offset, ct);
}
public async Task<Page<DatabaseMessageInfo>> DatabaseMessagesAsync(string accountId, string chatId, int limit, int offset, long? localId, CancellationToken ct)
{
RequireCapability("db-messages"); security.RequireCurrent(Identity, "read", accountId); ReadOnlyRequest.Page(limit, offset);
if (string.IsNullOrWhiteSpace(chatId) || chatId.Length > 512) throw new ServiceException("InvalidRequest", 400, "chatId is required and bounded.");
return (await backend.DatabaseMessagesAsync(accountId, chatId, Math.Min(500, limit + offset), localId, ct)).ToPage(limit, offset);
}
public async Task<MergedMessageInfo> DatabaseMergedAsync(string accountId, string chatId, long localId, CancellationToken ct)
{
RequireCapability("db-merged"); security.RequireCurrent(Identity, "read", accountId);
if (localId <= 0) throw new ServiceException("InvalidRequest", 400, "localId must be positive.");
return await backend.DatabaseMergedAsync(accountId, chatId, localId, ct);
}
public async Task<ArtifactInfo> UploadAsync(IFormFile file, CancellationToken ct)
{ security.RequireCurrent(Identity, "write"); return await artifacts.SaveAsync(Identity.PrincipalId, file, ct); }
public FileStream Download(string id)
{ security.RequireCurrent(Identity, "content"); return artifacts.Open(Identity.PrincipalId, id); }
public Page<OperationSummary> Operations(string? accountId, int limit, int offset)
{
ReadOnlyRequest.Page(limit, offset);
if (accountId is { Length: > 200 }) throw new ServiceException("InvalidRequest", 400, "accountId is too long.");
return operations.List(Identity, accountId, limit, offset);
}
public OperationRecord Operation(string id) => operations.Get(Identity, id);
public OperationRecord CancelOperation(string id) => operations.Cancel(Identity, id);
public IReadOnlyList<AgentCapability> Capabilities()
{
var identity = security.RequireCurrent(Identity, "read");
return backend.Capabilities.Select(c => identity.Allows(c.Permission) ? c :
c with { Enabled = false, DisabledReason = "Permission required." }).ToArray();
}
}