332 lines
21 KiB
C#
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();
|
|
}
|
|
}
|